Compare commits
5 Commits
f844ddddbb
...
b8ad8df993
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8ad8df993 | ||
| 2e3ecdf1d1 | |||
| 2194833509 | |||
| a0535ae12f | |||
| 98724af6a9 |
11
.gitignore
vendored
11
.gitignore
vendored
@ -46,3 +46,14 @@ walkguide-backend/demo/hs_err_pid*.log
|
|||||||
# Android SDK path (generated by Android Studio)
|
# Android SDK path (generated by Android Studio)
|
||||||
walkguide-mobile/walkguide_app/android/local.properties
|
walkguide-mobile/walkguide_app/android/local.properties
|
||||||
|
|
||||||
|
# Local Python/YOLO export artifacts - do not commit
|
||||||
|
walkguide-mobile/walkguide_app/.venv*/
|
||||||
|
walkguide-mobile/walkguide_app/yolov8n.onnx
|
||||||
|
walkguide-mobile/walkguide_app/yolov8n.pt
|
||||||
|
walkguide-mobile/walkguide_app/yolov8n_saved_model/
|
||||||
|
walkguide-mobile/walkguide_app/calibration_image_sample_data_*.npy
|
||||||
|
walkguide-mobile/walkguide_app/devtools_options.yaml
|
||||||
|
walkguide-mobile/walkguide_app/android/app/src/main/java/dev/flutter/plugins/
|
||||||
|
walkguide-mobile/walkguide_app/android/app/src/main/java/io/flutter/plugins/
|
||||||
|
new_file
|
||||||
|
|
||||||
|
|||||||
@ -4,12 +4,9 @@ import com.walkguide.dto.ApiResponse;
|
|||||||
import com.walkguide.dto.request.CallNotifyRequest;
|
import com.walkguide.dto.request.CallNotifyRequest;
|
||||||
import com.walkguide.dto.request.CallTokenRequest;
|
import com.walkguide.dto.request.CallTokenRequest;
|
||||||
import com.walkguide.dto.response.AgoraTokenResponse;
|
import com.walkguide.dto.response.AgoraTokenResponse;
|
||||||
import com.walkguide.entity.User;
|
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
|
||||||
import com.walkguide.repository.UserRepository;
|
|
||||||
import com.walkguide.security.SecurityHelper;
|
import com.walkguide.security.SecurityHelper;
|
||||||
import com.walkguide.service.AgoraTokenService;
|
import com.walkguide.service.AgoraTokenService;
|
||||||
import com.walkguide.service.FcmService;
|
import com.walkguide.service.CallNotificationService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@ -17,23 +14,13 @@ import jakarta.validation.Valid;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller untuk VoIP call via Agora RTC.
|
|
||||||
*
|
|
||||||
* FLOW:
|
|
||||||
* 1. Caller → POST /shared/call/token → dapat Agora token + channelName
|
|
||||||
* 2. Caller join Agora channel (di Flutter)
|
|
||||||
* 3. Caller → POST /shared/call/notify → FCM "Incoming Call" dikirim ke receiver
|
|
||||||
* 4. Receiver terima FCM → join channel yang sama
|
|
||||||
* 5. Audio call tersambung via Agora
|
|
||||||
*
|
|
||||||
* Route: /api/v1/shared/call
|
|
||||||
* Auth: Authenticated (Guardian ATAU User)
|
|
||||||
*/
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/shared/call")
|
@RequestMapping("/api/v1/shared/call")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -43,17 +30,10 @@ import java.util.Map;
|
|||||||
public class CallController {
|
public class CallController {
|
||||||
|
|
||||||
private final AgoraTokenService agoraTokenService;
|
private final AgoraTokenService agoraTokenService;
|
||||||
private final FcmService fcmService;
|
private final CallNotificationService callNotificationService;
|
||||||
private final UserRepository userRepository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate Agora RTC token untuk call session.
|
|
||||||
* Dipanggil oleh CALLER sebelum mulai call.
|
|
||||||
*
|
|
||||||
* POST /api/v1/shared/call/token
|
|
||||||
*/
|
|
||||||
@PostMapping("/token")
|
@PostMapping("/token")
|
||||||
@Operation(summary = "Generate Agora token", description = "Caller minta token sebelum join Agora channel")
|
@Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
|
||||||
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
|
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
|
||||||
@Valid @RequestBody CallTokenRequest req) {
|
@Valid @RequestBody CallTokenRequest req) {
|
||||||
|
|
||||||
@ -66,64 +46,16 @@ public class CallController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
|
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Kirim FCM "Incoming Call" ke receiver.
|
|
||||||
* Dipanggil oleh CALLER setelah berhasil join Agora channel.
|
|
||||||
*
|
|
||||||
* POST /api/v1/shared/call/notify
|
|
||||||
*/
|
|
||||||
@PostMapping("/notify")
|
@PostMapping("/notify")
|
||||||
@Operation(summary = "Notify receiver of incoming call",
|
@Operation(summary = "Notify receiver of incoming call")
|
||||||
description = "Kirim FCM push notification ke receiver agar join Agora channel yang sama")
|
|
||||||
public ResponseEntity<ApiResponse<Void>> notifyCall(
|
public ResponseEntity<ApiResponse<Void>> notifyCall(
|
||||||
@Valid @RequestBody CallNotifyRequest req) {
|
@Valid @RequestBody CallNotifyRequest req) {
|
||||||
|
|
||||||
Long callerId = SecurityHelper.getCurrentUserId();
|
Long callerId = SecurityHelper.getCurrentUserId();
|
||||||
|
String message = callNotificationService.notifyIncomingCall(callerId, req);
|
||||||
// Ambil info caller untuk notifikasi
|
return ResponseEntity.ok(ApiResponse.ok(null, message));
|
||||||
User caller = userRepository.findById(callerId)
|
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
|
|
||||||
|
|
||||||
// Ambil FCM token receiver
|
|
||||||
User receiver = userRepository.findById(req.getReceiverId())
|
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
|
|
||||||
|
|
||||||
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
|
|
||||||
log.warn("[CALL] Receiver {} tidak punya FCM token — call notify gagal", req.getReceiverId());
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null,
|
|
||||||
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload FCM untuk incoming call
|
|
||||||
Map<String, String> payload = Map.of(
|
|
||||||
"type", "INCOMING_CALL",
|
|
||||||
"callerId", String.valueOf(callerId),
|
|
||||||
"callerName", caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail(),
|
|
||||||
"channelName", req.getChannelName(),
|
|
||||||
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
|
|
||||||
"receiverUid", String.valueOf(req.getReceiverUid())
|
|
||||||
);
|
|
||||||
|
|
||||||
// High priority karena incoming call harus segera muncul
|
|
||||||
fcmService.sendHighPriority(
|
|
||||||
receiver.getFcmToken(),
|
|
||||||
"📞 Panggilan Masuk",
|
|
||||||
"Panggilan dari " + (caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail()),
|
|
||||||
payload
|
|
||||||
);
|
|
||||||
|
|
||||||
log.info("[CALL] Incoming call notification sent | caller={} receiver={} channel={}",
|
|
||||||
callerId, req.getReceiverId(), req.getChannelName());
|
|
||||||
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null, "Notifikasi panggilan berhasil dikirim"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End call — notifikasi ke pihak lain bahwa call sudah berakhir.
|
|
||||||
* Opsional: Flutter bisa handle end call secara lokal juga.
|
|
||||||
*
|
|
||||||
* POST /api/v1/shared/call/end
|
|
||||||
*/
|
|
||||||
@PostMapping("/end")
|
@PostMapping("/end")
|
||||||
@Operation(summary = "Notify end of call")
|
@Operation(summary = "Notify end of call")
|
||||||
public ResponseEntity<ApiResponse<Void>> endCall(
|
public ResponseEntity<ApiResponse<Void>> endCall(
|
||||||
@ -131,19 +63,7 @@ public class CallController {
|
|||||||
|
|
||||||
Long callerId = SecurityHelper.getCurrentUserId();
|
Long callerId = SecurityHelper.getCurrentUserId();
|
||||||
Long otherId = body.get("otherId");
|
Long otherId = body.get("otherId");
|
||||||
|
callNotificationService.notifyCallEnded(callerId, otherId);
|
||||||
if (otherId != null) {
|
|
||||||
userRepository.findById(otherId).ifPresent(other -> {
|
|
||||||
if (other.getFcmToken() != null && !other.getFcmToken().isBlank()) {
|
|
||||||
fcmService.sendToToken(
|
|
||||||
other.getFcmToken(),
|
|
||||||
"Panggilan Berakhir",
|
|
||||||
"Panggilan telah berakhir",
|
|
||||||
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
|
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,7 +51,7 @@ public class GuardianController {
|
|||||||
Long guardianId = SecurityHelper.getCurrentUserId();
|
Long guardianId = SecurityHelper.getCurrentUserId();
|
||||||
// Perlu ambil userId dulu — delegasikan ke service
|
// Perlu ambil userId dulu — delegasikan ke service
|
||||||
return ResponseEntity.ok(ApiResponse.ok(
|
return ResponseEntity.ok(ApiResponse.ok(
|
||||||
locationService.getLocationHistory(guardianId,
|
locationService.getLocationHistoryForGuardian(guardianId,
|
||||||
PageRequest.of(page, size, Sort.by("createdAt").descending())),
|
PageRequest.of(page, size, Sort.by("createdAt").descending())),
|
||||||
"Riwayat lokasi"));
|
"Riwayat lokasi"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,11 @@ package com.walkguide.controller;
|
|||||||
|
|
||||||
import com.walkguide.dto.ApiResponse;
|
import com.walkguide.dto.ApiResponse;
|
||||||
import com.walkguide.dto.request.*;
|
import com.walkguide.dto.request.*;
|
||||||
|
import com.walkguide.dto.response.PairingCodeResponse;
|
||||||
import com.walkguide.dto.response.PairingStatusResponse;
|
import com.walkguide.dto.response.PairingStatusResponse;
|
||||||
import com.walkguide.security.SecurityHelper;
|
import com.walkguide.security.SecurityHelper;
|
||||||
import com.walkguide.service.PairingService;
|
import com.walkguide.service.PairingService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@ -16,12 +18,26 @@ public class PairingController {
|
|||||||
|
|
||||||
private final PairingService pairingService;
|
private final PairingService pairingService;
|
||||||
|
|
||||||
|
@GetMapping("/code")
|
||||||
|
public ResponseEntity<ApiResponse<PairingCodeResponse>> code() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(
|
||||||
|
pairingService.getOrCreatePairingCode(SecurityHelper.getCurrentUserId()),
|
||||||
|
"Pairing code aktif"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/code/regenerate")
|
||||||
|
public ResponseEntity<ApiResponse<PairingCodeResponse>> regenerateCode() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(
|
||||||
|
pairingService.regeneratePairingCode(SecurityHelper.getCurrentUserId()),
|
||||||
|
"Pairing code baru dibuat"));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/invite")
|
@PostMapping("/invite")
|
||||||
public ResponseEntity<ApiResponse<PairingStatusResponse>> invite(
|
public ResponseEntity<ApiResponse<PairingStatusResponse>> invite(
|
||||||
@RequestBody InviteUserRequest req) {
|
@Valid @RequestBody InviteUserRequest req) {
|
||||||
Long guardianId = SecurityHelper.getCurrentUserId();
|
Long guardianId = SecurityHelper.getCurrentUserId();
|
||||||
return ResponseEntity.ok(ApiResponse.ok(
|
return ResponseEntity.ok(ApiResponse.ok(
|
||||||
pairingService.inviteUser(guardianId, req.getUniqueUserId()),
|
pairingService.inviteUser(guardianId, req.resolveSubmittedCode()),
|
||||||
"Undangan dikirim ke user"));
|
"Undangan dikirim ke user"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,16 @@ import lombok.Data;
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class InviteUserRequest {
|
public class InviteUserRequest {
|
||||||
@NotBlank(message = "User ID tidak boleh kosong")
|
|
||||||
@Size(min = 12, max = 12, message = "User ID harus tepat 12 karakter")
|
@Size(min = 12, max = 12, message = "User ID harus tepat 12 karakter")
|
||||||
private String uniqueUserId;
|
private String uniqueUserId;
|
||||||
|
|
||||||
|
@Size(min = 8, max = 8, message = "Pairing code harus tepat 8 karakter")
|
||||||
|
private String pairingCode;
|
||||||
|
|
||||||
|
public String resolveSubmittedCode() {
|
||||||
|
if (pairingCode != null && !pairingCode.isBlank()) {
|
||||||
|
return pairingCode.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
return uniqueUserId == null ? null : uniqueUserId.trim().toUpperCase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,14 @@
|
|||||||
|
package com.walkguide.dto.response;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class PairingCodeResponse {
|
||||||
|
private String pairingCode;
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
private long expiresInSeconds;
|
||||||
|
}
|
||||||
@ -12,6 +12,8 @@ public class PairingStatusResponse {
|
|||||||
private String pairedWithName; // nama Guardian (untuk User) atau nama User (untuk Guardian)
|
private String pairedWithName; // nama Guardian (untuk User) atau nama User (untuk Guardian)
|
||||||
private String pairedWithEmail;
|
private String pairedWithEmail;
|
||||||
private String uniqueUserId; // ID user yang di-pair
|
private String uniqueUserId; // ID user yang di-pair
|
||||||
|
private String pairingCode; // temporary code for new pairing flow
|
||||||
|
private LocalDateTime pairingCodeExpiresAt;
|
||||||
private LocalDateTime invitedAt;
|
private LocalDateTime invitedAt;
|
||||||
private LocalDateTime respondedAt;
|
private LocalDateTime respondedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,12 @@ public class User {
|
|||||||
@Column(name = "unique_user_id", unique = true, length = 12)
|
@Column(name = "unique_user_id", unique = true, length = 12)
|
||||||
private String uniqueUserId;
|
private String uniqueUserId;
|
||||||
|
|
||||||
|
@Column(name = "pairing_code", unique = true, length = 8)
|
||||||
|
private String pairingCode;
|
||||||
|
|
||||||
|
@Column(name = "pairing_code_expires_at")
|
||||||
|
private LocalDateTime pairingCodeExpiresAt;
|
||||||
|
|
||||||
@Column(name = "display_name", length = 100)
|
@Column(name = "display_name", length = 100)
|
||||||
private String displayName;
|
private String displayName;
|
||||||
|
|
||||||
|
|||||||
@ -9,5 +9,6 @@ import java.util.Optional;
|
|||||||
public interface UserRepository extends JpaRepository<User, Long> {
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
Optional<User> findByEmail(String email);
|
Optional<User> findByEmail(String email);
|
||||||
Optional<User> findByUniqueUserId(String uniqueUserId);
|
Optional<User> findByUniqueUserId(String uniqueUserId);
|
||||||
|
Optional<User> findByPairingCode(String pairingCode);
|
||||||
boolean existsByEmail(String email);
|
boolean existsByEmail(String email);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
package com.walkguide.service;
|
||||||
|
|
||||||
|
import com.walkguide.dto.request.CallNotifyRequest;
|
||||||
|
import com.walkguide.entity.User;
|
||||||
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
|
import com.walkguide.repository.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class CallNotificationService {
|
||||||
|
|
||||||
|
private final FcmService fcmService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public String notifyIncomingCall(Long callerId, CallNotifyRequest req) {
|
||||||
|
User caller = userRepository.findById(callerId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
|
||||||
|
|
||||||
|
User receiver = userRepository.findById(req.getReceiverId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
|
||||||
|
|
||||||
|
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
|
||||||
|
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId());
|
||||||
|
return "Panggilan dikirim (receiver mungkin tidak menerima push notification)";
|
||||||
|
}
|
||||||
|
|
||||||
|
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
|
||||||
|
Map<String, String> payload = Map.of(
|
||||||
|
"type", "INCOMING_CALL",
|
||||||
|
"callerId", String.valueOf(callerId),
|
||||||
|
"callerName", callerName,
|
||||||
|
"channelName", req.getChannelName(),
|
||||||
|
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
|
||||||
|
"receiverUid", String.valueOf(req.getReceiverUid())
|
||||||
|
);
|
||||||
|
|
||||||
|
fcmService.sendHighPriority(
|
||||||
|
receiver.getFcmToken(),
|
||||||
|
"Panggilan Masuk",
|
||||||
|
"Panggilan dari " + callerName,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("[CALL] Incoming call notification sent | caller={} receiver={} channel={}",
|
||||||
|
callerId, req.getReceiverId(), req.getChannelName());
|
||||||
|
return "Notifikasi panggilan berhasil dikirim";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void notifyCallEnded(Long callerId, Long otherId) {
|
||||||
|
if (otherId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepository.findById(otherId).ifPresent(other -> {
|
||||||
|
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fcmService.sendToToken(
|
||||||
|
other.getFcmToken(),
|
||||||
|
"Panggilan Berakhir",
|
||||||
|
"Panggilan telah berakhir",
|
||||||
|
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import com.walkguide.entity.LocationHistory;
|
|||||||
import com.walkguide.entity.User;
|
import com.walkguide.entity.User;
|
||||||
import com.walkguide.enums.ActivityLogType;
|
import com.walkguide.enums.ActivityLogType;
|
||||||
import com.walkguide.enums.PairingStatus;
|
import com.walkguide.enums.PairingStatus;
|
||||||
|
import com.walkguide.exception.PairingException;
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
import com.walkguide.repository.*;
|
import com.walkguide.repository.*;
|
||||||
import com.walkguide.websocket.LocationBroadcaster;
|
import com.walkguide.websocket.LocationBroadcaster;
|
||||||
@ -79,6 +80,15 @@ public class LocationService {
|
|||||||
.map(this::toResponse);
|
.map(this::toResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Page<LocationResponse> getLocationHistoryForGuardian(Long guardianId, Pageable pageable) {
|
||||||
|
Long pairedUserId = pairingRelationRepository
|
||||||
|
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
|
||||||
|
.map(pairing -> pairing.getUser().getId())
|
||||||
|
.orElseThrow(() -> new PairingException("Guardian belum terhubung dengan User aktif"));
|
||||||
|
|
||||||
|
return getLocationHistory(pairedUserId, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== GEOFENCE ==========
|
// ========== GEOFENCE ==========
|
||||||
|
|
||||||
private void checkGeofence(Long userId, double lat, double lng) {
|
private void checkGeofence(Long userId, double lat, double lng) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.walkguide.service;
|
package com.walkguide.service;
|
||||||
|
|
||||||
import com.walkguide.dto.response.PairingStatusResponse;
|
import com.walkguide.dto.response.PairingStatusResponse;
|
||||||
|
import com.walkguide.dto.response.PairingCodeResponse;
|
||||||
import com.walkguide.entity.*;
|
import com.walkguide.entity.*;
|
||||||
import com.walkguide.enums.*;
|
import com.walkguide.enums.*;
|
||||||
import com.walkguide.exception.PairingException;
|
import com.walkguide.exception.PairingException;
|
||||||
@ -10,7 +11,9 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -26,8 +29,46 @@ public class PairingService {
|
|||||||
private final ActivityLogService activityLogService;
|
private final ActivityLogService activityLogService;
|
||||||
private final FcmService fcmService;
|
private final FcmService fcmService;
|
||||||
|
|
||||||
|
private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
private static final int PAIRING_CODE_LENGTH = 8;
|
||||||
|
private static final int PAIRING_CODE_TTL_MINUTES = 15;
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public PairingStatusResponse inviteUser(Long guardianId, String uniqueUserId) {
|
public PairingCodeResponse getOrCreatePairingCode(Long userId) {
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
|
||||||
|
if (!"ROLE_USER".equals(user.getRole())) {
|
||||||
|
throw new PairingException("Hanya akun User yang bisa membuat pairing code.");
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
if (user.getPairingCode() == null
|
||||||
|
|| user.getPairingCodeExpiresAt() == null
|
||||||
|
|| !user.getPairingCodeExpiresAt().isAfter(now)) {
|
||||||
|
assignNewPairingCode(user, now);
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
return buildPairingCodeResponse(user, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public PairingCodeResponse regeneratePairingCode(Long userId) {
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
|
||||||
|
if (!"ROLE_USER".equals(user.getRole())) {
|
||||||
|
throw new PairingException("Hanya akun User yang bisa membuat pairing code.");
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
assignNewPairingCode(user, now);
|
||||||
|
userRepository.save(user);
|
||||||
|
activityLogService.createLog(user, ActivityLogType.PAIRING_INVITE_SENT,
|
||||||
|
"User membuat pairing code baru", null);
|
||||||
|
return buildPairingCodeResponse(user, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
|
||||||
// Guardian tidak boleh punya pairing ACTIVE atau PENDING
|
// Guardian tidak boleh punya pairing ACTIVE atau PENDING
|
||||||
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
|
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
|
||||||
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
|
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
|
||||||
@ -38,8 +79,7 @@ public class PairingService {
|
|||||||
|
|
||||||
User guardian = userRepository.findById(guardianId)
|
User guardian = userRepository.findById(guardianId)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Guardian tidak ditemukan"));
|
.orElseThrow(() -> new ResourceNotFoundException("Guardian tidak ditemukan"));
|
||||||
User user = userRepository.findByUniqueUserId(uniqueUserId)
|
User user = resolveUserByPairingCode(submittedCode);
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("User dengan ID '" + uniqueUserId + "' tidak ditemukan"));
|
|
||||||
|
|
||||||
if (!"ROLE_USER".equals(user.getRole())) {
|
if (!"ROLE_USER".equals(user.getRole())) {
|
||||||
throw new PairingException("ID tersebut bukan milik User. Pastikan kamu memasukkan ID yang benar.");
|
throw new PairingException("ID tersebut bukan milik User. Pastikan kamu memasukkan ID yang benar.");
|
||||||
@ -55,6 +95,10 @@ public class PairingService {
|
|||||||
.build();
|
.build();
|
||||||
pairing = pairingRelationRepository.save(pairing);
|
pairing = pairingRelationRepository.save(pairing);
|
||||||
|
|
||||||
|
user.setPairingCode(null);
|
||||||
|
user.setPairingCodeExpiresAt(null);
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
// Kirim FCM ke user
|
// Kirim FCM ke user
|
||||||
fcmService.sendToToken(user.getFcmToken(),
|
fcmService.sendToToken(user.getFcmToken(),
|
||||||
"Pairing Request",
|
"Pairing Request",
|
||||||
@ -199,6 +243,51 @@ public class PairingService {
|
|||||||
.enabled(name != null).build();
|
.enabled(name != null).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private User resolveUserByPairingCode(String submittedCode) {
|
||||||
|
if (submittedCode == null || submittedCode.isBlank()) {
|
||||||
|
throw new PairingException("Pairing code tidak boleh kosong.");
|
||||||
|
}
|
||||||
|
String code = submittedCode.trim().toUpperCase();
|
||||||
|
User user = userRepository.findByPairingCode(code)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException(
|
||||||
|
"Pairing code '" + code + "' tidak ditemukan. Minta User generate kode baru."));
|
||||||
|
if (user.getPairingCodeExpiresAt() == null
|
||||||
|
|| !user.getPairingCodeExpiresAt().isAfter(LocalDateTime.now())) {
|
||||||
|
user.setPairingCode(null);
|
||||||
|
user.setPairingCodeExpiresAt(null);
|
||||||
|
userRepository.save(user);
|
||||||
|
throw new PairingException("Pairing code sudah kadaluarsa. Minta User generate kode baru.");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assignNewPairingCode(User user, LocalDateTime now) {
|
||||||
|
String candidate;
|
||||||
|
do {
|
||||||
|
candidate = randomCode();
|
||||||
|
} while (userRepository.findByPairingCode(candidate).isPresent());
|
||||||
|
user.setPairingCode(candidate);
|
||||||
|
user.setPairingCodeExpiresAt(now.plusMinutes(PAIRING_CODE_TTL_MINUTES));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String randomCode() {
|
||||||
|
StringBuilder sb = new StringBuilder(PAIRING_CODE_LENGTH);
|
||||||
|
for (int i = 0; i < PAIRING_CODE_LENGTH; i++) {
|
||||||
|
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PairingCodeResponse buildPairingCodeResponse(User user, LocalDateTime now) {
|
||||||
|
long seconds = Math.max(0,
|
||||||
|
ChronoUnit.SECONDS.between(now, user.getPairingCodeExpiresAt()));
|
||||||
|
return PairingCodeResponse.builder()
|
||||||
|
.pairingCode(user.getPairingCode())
|
||||||
|
.expiresAt(user.getPairingCodeExpiresAt())
|
||||||
|
.expiresInSeconds(seconds)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
private PairingStatusResponse buildStatus(PairingRelation p, User guardian, User user, String viewerRole) {
|
private PairingStatusResponse buildStatus(PairingRelation p, User guardian, User user, String viewerRole) {
|
||||||
String pairedWithName = "GUARDIAN".equals(viewerRole)
|
String pairedWithName = "GUARDIAN".equals(viewerRole)
|
||||||
? user.getDisplayName() : guardian.getDisplayName();
|
? user.getDisplayName() : guardian.getDisplayName();
|
||||||
@ -211,6 +300,8 @@ public class PairingService {
|
|||||||
.pairedWithName(pairedWithName)
|
.pairedWithName(pairedWithName)
|
||||||
.pairedWithEmail(pairedWithEmail)
|
.pairedWithEmail(pairedWithEmail)
|
||||||
.uniqueUserId(user.getUniqueUserId())
|
.uniqueUserId(user.getUniqueUserId())
|
||||||
|
.pairingCode(user.getPairingCode())
|
||||||
|
.pairingCodeExpiresAt(user.getPairingCodeExpiresAt())
|
||||||
.invitedAt(p.getInvitedAt())
|
.invitedAt(p.getInvitedAt())
|
||||||
.respondedAt(p.getRespondedAt())
|
.respondedAt(p.getRespondedAt())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
datasource:
|
||||||
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
url: ${DB_URL}
|
||||||
username: ${DB_USERNAME:5803024001}
|
username: ${DB_USERNAME}
|
||||||
password: ${DB_PASSWORD:pw5803024001}
|
password: ${DB_PASSWORD}
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
show-sql: true
|
show-sql: true
|
||||||
@ -17,8 +17,8 @@ spring:
|
|||||||
format_sql: true
|
format_sql: true
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970}
|
secret: ${JWT_SECRET}
|
||||||
expiration: 86400000
|
expiration: ${JWT_EXPIRATION:86400000}
|
||||||
|
|
||||||
agora:
|
agora:
|
||||||
app-id: ${AGORA_APP_ID:}
|
app-id: ${AGORA_APP_ID:}
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
-- V17: Expiring pairing code for lecturer revision.
|
||||||
|
-- unique_user_id remains a stable account identifier; pairing_code is temporary.
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS pairing_code VARCHAR(8) UNIQUE,
|
||||||
|
ADD COLUMN IF NOT EXISTS pairing_code_expires_at TIMESTAMP;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_pairing_code ON users(pairing_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_pairing_code_expires_at ON users(pairing_code_expires_at);
|
||||||
@ -4,7 +4,8 @@ info:
|
|||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: Design contract for WalkGuide Flutter and Spring Boot integration.
|
description: Design contract for WalkGuide Flutter and Spring Boot integration.
|
||||||
servers:
|
servers:
|
||||||
- url: http://localhost:8080/api/v1
|
- url: https://api.walkguide.example/api/v1
|
||||||
|
description: Production deployment URL placeholder
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
components:
|
components:
|
||||||
@ -37,9 +38,16 @@ components:
|
|||||||
role: { type: string, enum: [USER, GUARDIAN] }
|
role: { type: string, enum: [USER, GUARDIAN] }
|
||||||
PairingInviteRequest:
|
PairingInviteRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [uniqueUserId]
|
required: [pairingCode]
|
||||||
properties:
|
properties:
|
||||||
uniqueUserId: { type: string, minLength: 12, maxLength: 12 }
|
pairingCode: { type: string, minLength: 8, maxLength: 8, description: "Temporary code generated by the User app; expires automatically." }
|
||||||
|
uniqueUserId: { type: string, minLength: 12, maxLength: 12, deprecated: true }
|
||||||
|
PairingCodeResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
pairingCode: { type: string }
|
||||||
|
expiresAt: { type: string, format: date-time }
|
||||||
|
expiresInSeconds: { type: integer, format: int64 }
|
||||||
PairingRespondRequest:
|
PairingRespondRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [pairingId, accept]
|
required: [pairingId, accept]
|
||||||
@ -107,6 +115,14 @@ paths:
|
|||||||
schema: { $ref: "#/components/schemas/PairingInviteRequest" }
|
schema: { $ref: "#/components/schemas/PairingInviteRequest" }
|
||||||
responses:
|
responses:
|
||||||
"200": { description: Invite sent }
|
"200": { description: Invite sent }
|
||||||
|
/shared/pairing/code:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
"200": { description: Active expiring pairing code }
|
||||||
|
/shared/pairing/code/regenerate:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
"200": { description: New expiring pairing code generated }
|
||||||
/shared/pairing/respond:
|
/shared/pairing/respond:
|
||||||
post:
|
post:
|
||||||
requestBody:
|
requestBody:
|
||||||
|
|||||||
@ -4,13 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import com.walkguide.dto.request.CallNotifyRequest;
|
import com.walkguide.dto.request.CallNotifyRequest;
|
||||||
import com.walkguide.dto.request.CallTokenRequest;
|
import com.walkguide.dto.request.CallTokenRequest;
|
||||||
import com.walkguide.dto.response.AgoraTokenResponse;
|
import com.walkguide.dto.response.AgoraTokenResponse;
|
||||||
import com.walkguide.entity.User;
|
import com.walkguide.security.JwtAuthFilter;
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
|
||||||
import com.walkguide.repository.UserRepository;
|
|
||||||
import com.walkguide.security.SecurityHelper;
|
import com.walkguide.security.SecurityHelper;
|
||||||
import com.walkguide.service.AgoraTokenService;
|
import com.walkguide.service.AgoraTokenService;
|
||||||
import com.walkguide.service.FcmService;
|
import com.walkguide.service.CallNotificationService;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
@ -18,19 +15,22 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
import com.walkguide.security.JwtAuthFilter;
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.test.context.support.WithMockUser;
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mockStatic;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@AutoConfigureMockMvc(addFilters = false)
|
@AutoConfigureMockMvc(addFilters = false)
|
||||||
@WebMvcTest(CallController.class)
|
@WebMvcTest(CallController.class)
|
||||||
@ -38,7 +38,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
@DisplayName("CallController Unit Tests")
|
@DisplayName("CallController Unit Tests")
|
||||||
class CallControllerTest {
|
class CallControllerTest {
|
||||||
|
|
||||||
@MockBean private JwtAuthFilter jwtAuthFilter;
|
@MockBean
|
||||||
|
private JwtAuthFilter jwtAuthFilter;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
@ -50,35 +51,10 @@ class CallControllerTest {
|
|||||||
private AgoraTokenService agoraTokenService;
|
private AgoraTokenService agoraTokenService;
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private FcmService fcmService;
|
private CallNotificationService callNotificationService;
|
||||||
|
|
||||||
@MockBean
|
|
||||||
private UserRepository userRepository;
|
|
||||||
|
|
||||||
private User sampleCaller;
|
|
||||||
private User sampleReceiver;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
sampleCaller = User.builder()
|
|
||||||
.id(1L)
|
|
||||||
.email("caller@test.com")
|
|
||||||
.displayName("Caller User")
|
|
||||||
.fcmToken("fcm-caller-token")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
sampleReceiver = User.builder()
|
|
||||||
.id(2L)
|
|
||||||
.email("receiver@test.com")
|
|
||||||
.displayName("Receiver Guardian")
|
|
||||||
.fcmToken("fcm-receiver-token")
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== GENERATE TOKEN =====
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("POST /api/v1/shared/call/token - valid request harus return Agora token")
|
@DisplayName("POST /api/v1/shared/call/token - valid request returns Agora token")
|
||||||
void generateToken_validRequest_shouldReturn200() throws Exception {
|
void generateToken_validRequest_shouldReturn200() throws Exception {
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
||||||
@ -88,7 +64,7 @@ class CallControllerTest {
|
|||||||
|
|
||||||
AgoraTokenResponse tokenResp = AgoraTokenResponse.builder()
|
AgoraTokenResponse tokenResp = AgoraTokenResponse.builder()
|
||||||
.token("agora-rtc-token-xyz")
|
.token("agora-rtc-token-xyz")
|
||||||
.channelName("call_1_2_1234567890")
|
.channelName("call_1_2")
|
||||||
.uid(1001)
|
.uid(1001)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@ -102,34 +78,24 @@ class CallControllerTest {
|
|||||||
.andExpect(jsonPath("$.success").value(true))
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
.andExpect(jsonPath("$.message").value("Token Agora berhasil digenerate"))
|
.andExpect(jsonPath("$.message").value("Token Agora berhasil digenerate"))
|
||||||
.andExpect(jsonPath("$.data.token").value("agora-rtc-token-xyz"))
|
.andExpect(jsonPath("$.data.token").value("agora-rtc-token-xyz"))
|
||||||
.andExpect(jsonPath("$.data.channelName").value("call_1_2_1234567890"));
|
.andExpect(jsonPath("$.data.channelName").value("call_1_2"));
|
||||||
|
|
||||||
verify(agoraTokenService).generateToken(1L, 2L);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("POST /api/v1/shared/call/token - receiverId null harus return 400")
|
@DisplayName("POST /api/v1/shared/call/token - null receiverId returns 400")
|
||||||
void generateToken_nullReceiverId_shouldReturn400() throws Exception {
|
void generateToken_nullReceiverId_shouldReturn400() throws Exception {
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
|
||||||
|
|
||||||
// CallTokenRequest dengan receiverId null — @Valid harus menolak
|
|
||||||
CallTokenRequest req = new CallTokenRequest();
|
CallTokenRequest req = new CallTokenRequest();
|
||||||
// receiverId tidak di-set (null)
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/token")
|
mockMvc.perform(post("/api/v1/shared/call/token")
|
||||||
.with(csrf())
|
.with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
|
|
||||||
verify(agoraTokenService, never()).generateToken(anyLong(), anyLong());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("POST /api/v1/shared/call/token - service throw harus return 500")
|
@DisplayName("POST /api/v1/shared/call/token - service error returns 500")
|
||||||
void generateToken_serviceThrows_shouldReturn500() throws Exception {
|
void generateToken_serviceThrows_shouldReturn500() throws Exception {
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
||||||
@ -148,21 +114,17 @@ class CallControllerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== NOTIFY CALL =====
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("POST /api/v1/shared/call/notify - receiver punya FCM token harus kirim notifikasi")
|
@DisplayName("POST /api/v1/shared/call/notify - delegates to call notification service")
|
||||||
void notifyCall_receiverHasFcmToken_shouldReturn200AndSendFcm() throws Exception {
|
void notifyCall_validRequest_shouldReturnServiceMessage() throws Exception {
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
||||||
|
when(callNotificationService.notifyIncomingCall(eq(1L), any(CallNotifyRequest.class)))
|
||||||
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
|
.thenReturn("Notifikasi panggilan berhasil dikirim");
|
||||||
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
|
|
||||||
doNothing().when(fcmService).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
|
|
||||||
|
|
||||||
CallNotifyRequest req = new CallNotifyRequest();
|
CallNotifyRequest req = new CallNotifyRequest();
|
||||||
req.setReceiverId(2L);
|
req.setReceiverId(2L);
|
||||||
req.setChannelName("call_1_2_1234567890");
|
req.setChannelName("call_1_2");
|
||||||
req.setAgoraToken("agora-token-xyz");
|
req.setAgoraToken("agora-token-xyz");
|
||||||
req.setReceiverUid(1002);
|
req.setReceiverUid(1002);
|
||||||
|
|
||||||
@ -174,260 +136,40 @@ class CallControllerTest {
|
|||||||
.andExpect(jsonPath("$.success").value(true))
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
.andExpect(jsonPath("$.message").value("Notifikasi panggilan berhasil dikirim"));
|
.andExpect(jsonPath("$.message").value("Notifikasi panggilan berhasil dikirim"));
|
||||||
|
|
||||||
verify(fcmService).sendHighPriority(
|
verify(callNotificationService).notifyIncomingCall(eq(1L), any(CallNotifyRequest.class));
|
||||||
eq("fcm-receiver-token"),
|
|
||||||
eq("📞 Panggilan Masuk"),
|
|
||||||
contains("Caller User"),
|
|
||||||
anyMap()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("POST /api/v1/shared/call/notify - receiver tidak punya FCM token harus return 200 tanpa FCM")
|
@DisplayName("POST /api/v1/shared/call/notify - validation failure does not call service")
|
||||||
void notifyCall_receiverNoFcmToken_shouldReturn200WithWarningMessage() throws Exception {
|
void notifyCall_invalidRequest_shouldReturn400() throws Exception {
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
|
||||||
|
|
||||||
User receiverNoFcm = User.builder()
|
|
||||||
.id(2L)
|
|
||||||
.email("receiver@test.com")
|
|
||||||
.displayName("Receiver")
|
|
||||||
.fcmToken(null) // tidak punya FCM token
|
|
||||||
.build();
|
|
||||||
|
|
||||||
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
|
|
||||||
when(userRepository.findById(2L)).thenReturn(Optional.of(receiverNoFcm));
|
|
||||||
|
|
||||||
CallNotifyRequest req = new CallNotifyRequest();
|
CallNotifyRequest req = new CallNotifyRequest();
|
||||||
req.setReceiverId(2L);
|
req.setChannelName("call_1_2");
|
||||||
req.setChannelName("call_1_2_abc");
|
|
||||||
req.setAgoraToken("token-xyz");
|
|
||||||
req.setReceiverUid(1002);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/notify")
|
mockMvc.perform(post("/api/v1/shared/call/notify")
|
||||||
.with(csrf())
|
.with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isBadRequest());
|
||||||
.andExpect(jsonPath("$.message").value(
|
|
||||||
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
|
|
||||||
|
|
||||||
// FCM tidak boleh dipanggil karena tidak ada token
|
verify(callNotificationService, never()).notifyIncomingCall(any(), any());
|
||||||
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("POST /api/v1/shared/call/notify - receiver FCM token blank harus return 200 tanpa FCM")
|
@DisplayName("POST /api/v1/shared/call/end - delegates to call notification service")
|
||||||
void notifyCall_receiverBlankFcmToken_shouldReturn200WithoutFcm() throws Exception {
|
void endCall_validOtherId_shouldDelegateToService() throws Exception {
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
||||||
|
|
||||||
User receiverBlankFcm = User.builder()
|
|
||||||
.id(2L)
|
|
||||||
.email("receiver@test.com")
|
|
||||||
.displayName("Receiver")
|
|
||||||
.fcmToken(" ") // blank
|
|
||||||
.build();
|
|
||||||
|
|
||||||
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
|
|
||||||
when(userRepository.findById(2L)).thenReturn(Optional.of(receiverBlankFcm));
|
|
||||||
|
|
||||||
CallNotifyRequest req = new CallNotifyRequest();
|
|
||||||
req.setReceiverId(2L);
|
|
||||||
req.setChannelName("channel-abc");
|
|
||||||
req.setAgoraToken("token");
|
|
||||||
req.setReceiverUid(1002);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/notify")
|
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.message").value(
|
|
||||||
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
|
|
||||||
|
|
||||||
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/v1/shared/call/notify - caller tidak ditemukan harus return 404")
|
|
||||||
void notifyCall_callerNotFound_shouldReturn404() throws Exception {
|
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
|
||||||
when(userRepository.findById(1L))
|
|
||||||
.thenThrow(new ResourceNotFoundException("Caller not found"));
|
|
||||||
|
|
||||||
CallNotifyRequest req = new CallNotifyRequest();
|
|
||||||
req.setReceiverId(2L);
|
|
||||||
req.setChannelName("channel-abc");
|
|
||||||
req.setAgoraToken("token");
|
|
||||||
req.setReceiverUid(1002);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/notify")
|
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
|
||||||
.andExpect(status().isNotFound());
|
|
||||||
|
|
||||||
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/v1/shared/call/notify - receiver tidak ditemukan harus return 404")
|
|
||||||
void notifyCall_receiverNotFound_shouldReturn404() throws Exception {
|
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
|
||||||
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
|
|
||||||
when(userRepository.findById(2L))
|
|
||||||
.thenThrow(new ResourceNotFoundException("Receiver not found"));
|
|
||||||
|
|
||||||
CallNotifyRequest req = new CallNotifyRequest();
|
|
||||||
req.setReceiverId(2L);
|
|
||||||
req.setChannelName("channel-abc");
|
|
||||||
req.setAgoraToken("token");
|
|
||||||
req.setReceiverUid(1002);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/notify")
|
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
|
||||||
.andExpect(status().isNotFound());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/v1/shared/call/notify - caller displayName null harus pakai email sebagai pengganti")
|
|
||||||
void notifyCall_callerNoDisplayName_shouldUseEmailAsFallback() throws Exception {
|
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
|
||||||
|
|
||||||
User callerNoName = User.builder()
|
|
||||||
.id(1L)
|
|
||||||
.email("noreply@test.com")
|
|
||||||
.displayName(null) // tidak ada displayName
|
|
||||||
.build();
|
|
||||||
|
|
||||||
when(userRepository.findById(1L)).thenReturn(Optional.of(callerNoName));
|
|
||||||
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
|
|
||||||
doNothing().when(fcmService).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
|
|
||||||
|
|
||||||
CallNotifyRequest req = new CallNotifyRequest();
|
|
||||||
req.setReceiverId(2L);
|
|
||||||
req.setChannelName("channel-abc");
|
|
||||||
req.setAgoraToken("token");
|
|
||||||
req.setReceiverUid(1002);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/notify")
|
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
// Pastikan body notifikasi menggunakan email sebagai fallback
|
|
||||||
verify(fcmService).sendHighPriority(
|
|
||||||
anyString(),
|
|
||||||
anyString(),
|
|
||||||
contains("noreply@test.com"),
|
|
||||||
anyMap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== END CALL =====
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/v1/shared/call/end - otherId valid dan punya FCM token harus kirim notifikasi berakhir")
|
|
||||||
void endCall_otherHasFcmToken_shouldSendEndNotification() throws Exception {
|
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
|
||||||
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
|
|
||||||
doNothing().when(fcmService).sendToToken(anyString(), anyString(), anyString(), anyMap());
|
|
||||||
|
|
||||||
Map<String, Long> body = Map.of("otherId", 2L);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/end")
|
mockMvc.perform(post("/api/v1/shared/call/end")
|
||||||
.with(csrf())
|
.with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(body)))
|
.content(objectMapper.writeValueAsString(Map.of("otherId", 2L))))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
.andExpect(jsonPath("$.message").value("Call ended"));
|
.andExpect(jsonPath("$.message").value("Call ended"));
|
||||||
|
|
||||||
verify(fcmService).sendToToken(
|
verify(callNotificationService).notifyCallEnded(1L, 2L);
|
||||||
eq("fcm-receiver-token"),
|
|
||||||
eq("Panggilan Berakhir"),
|
|
||||||
eq("Panggilan telah berakhir"),
|
|
||||||
anyMap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/v1/shared/call/end - otherId null harus return 200 tanpa kirim FCM")
|
|
||||||
void endCall_nullOtherId_shouldReturn200WithoutFcm() throws Exception {
|
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
|
||||||
|
|
||||||
Map<String, Long> body = Map.of(); // tidak ada otherId
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/end")
|
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(body)))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.message").value("Call ended"));
|
|
||||||
|
|
||||||
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/v1/shared/call/end - other tidak punya FCM token harus return 200 tanpa FCM")
|
|
||||||
void endCall_otherNoFcmToken_shouldReturn200WithoutFcm() throws Exception {
|
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
|
|
||||||
|
|
||||||
User otherNoFcm = User.builder()
|
|
||||||
.id(2L)
|
|
||||||
.email("other@test.com")
|
|
||||||
.fcmToken(null)
|
|
||||||
.build();
|
|
||||||
when(userRepository.findById(2L)).thenReturn(Optional.of(otherNoFcm));
|
|
||||||
|
|
||||||
Map<String, Long> body = Map.of("otherId", 2L);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/end")
|
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(body)))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.message").value("Call ended"));
|
|
||||||
|
|
||||||
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "2", roles = "GUARDIAN")
|
|
||||||
@DisplayName("POST /api/v1/shared/call/end - Guardian juga bisa end call")
|
|
||||||
void endCall_asGuardian_shouldReturn200() throws Exception {
|
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
|
|
||||||
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
|
|
||||||
doNothing().when(fcmService).sendToToken(anyString(), anyString(), anyString(), anyMap());
|
|
||||||
|
|
||||||
Map<String, Long> body = Map.of("otherId", 1L);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/call/end")
|
|
||||||
.with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(body)))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.message").value("Call ended"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,9 +92,9 @@ class PairingControllerTest {
|
|||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
|
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
|
||||||
|
|
||||||
InviteUserRequest req = new InviteUserRequest();
|
InviteUserRequest req = new InviteUserRequest();
|
||||||
req.setUniqueUserId("INVALID999");
|
req.setPairingCode("BAD99999");
|
||||||
|
|
||||||
when(pairingService.inviteUser(2L, "INVALID999"))
|
when(pairingService.inviteUser(2L, "BAD99999"))
|
||||||
.thenThrow(new RuntimeException("User dengan ID tersebut tidak ditemukan"));
|
.thenThrow(new RuntimeException("User dengan ID tersebut tidak ditemukan"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/v1/shared/pairing/invite")
|
mockMvc.perform(post("/api/v1/shared/pairing/invite")
|
||||||
|
|||||||
@ -0,0 +1,128 @@
|
|||||||
|
package com.walkguide.service;
|
||||||
|
|
||||||
|
import com.walkguide.dto.request.CallNotifyRequest;
|
||||||
|
import com.walkguide.entity.User;
|
||||||
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
|
import com.walkguide.repository.UserRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyMap;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("CallNotificationService Unit Tests")
|
||||||
|
class CallNotificationServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FcmService fcmService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private CallNotificationService service;
|
||||||
|
|
||||||
|
private User caller;
|
||||||
|
private User receiver;
|
||||||
|
private CallNotifyRequest request;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
caller = User.builder()
|
||||||
|
.id(1L)
|
||||||
|
.email("caller@test.com")
|
||||||
|
.displayName("Caller User")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
receiver = User.builder()
|
||||||
|
.id(2L)
|
||||||
|
.email("receiver@test.com")
|
||||||
|
.displayName("Receiver")
|
||||||
|
.fcmToken("receiver-token")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
request = new CallNotifyRequest();
|
||||||
|
request.setReceiverId(2L);
|
||||||
|
request.setChannelName("call_1_2");
|
||||||
|
request.setAgoraToken("agora-token");
|
||||||
|
request.setReceiverUid(1002);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notifyIncomingCall sends high priority FCM when receiver has token")
|
||||||
|
void notifyIncomingCall_receiverHasToken_shouldSendFcm() {
|
||||||
|
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
|
||||||
|
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
|
||||||
|
|
||||||
|
String message = service.notifyIncomingCall(1L, request);
|
||||||
|
|
||||||
|
assertEquals("Notifikasi panggilan berhasil dikirim", message);
|
||||||
|
verify(fcmService).sendHighPriority(
|
||||||
|
eq("receiver-token"),
|
||||||
|
eq("Panggilan Masuk"),
|
||||||
|
contains("Caller User"),
|
||||||
|
anyMap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notifyIncomingCall skips FCM when receiver token is missing")
|
||||||
|
void notifyIncomingCall_receiverHasNoToken_shouldSkipFcm() {
|
||||||
|
receiver.setFcmToken(null);
|
||||||
|
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
|
||||||
|
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
|
||||||
|
|
||||||
|
String message = service.notifyIncomingCall(1L, request);
|
||||||
|
|
||||||
|
assertEquals("Panggilan dikirim (receiver mungkin tidak menerima push notification)", message);
|
||||||
|
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notifyIncomingCall throws 404 when caller is missing")
|
||||||
|
void notifyIncomingCall_missingCaller_shouldThrow() {
|
||||||
|
when(userRepository.findById(1L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThrows(ResourceNotFoundException.class, () -> service.notifyIncomingCall(1L, request));
|
||||||
|
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notifyCallEnded sends FCM when other user has token")
|
||||||
|
void notifyCallEnded_otherHasToken_shouldSendFcm() {
|
||||||
|
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
|
||||||
|
|
||||||
|
service.notifyCallEnded(1L, 2L);
|
||||||
|
|
||||||
|
verify(fcmService).sendToToken(
|
||||||
|
eq("receiver-token"),
|
||||||
|
eq("Panggilan Berakhir"),
|
||||||
|
eq("Panggilan telah berakhir"),
|
||||||
|
anyMap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notifyCallEnded skips repository lookup when otherId is null")
|
||||||
|
void notifyCallEnded_nullOtherId_shouldReturn() {
|
||||||
|
service.notifyCallEnded(1L, null);
|
||||||
|
|
||||||
|
verify(userRepository, never()).findById(2L);
|
||||||
|
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import com.walkguide.entity.LocationHistory;
|
|||||||
import com.walkguide.entity.PairingRelation;
|
import com.walkguide.entity.PairingRelation;
|
||||||
import com.walkguide.entity.User;
|
import com.walkguide.entity.User;
|
||||||
import com.walkguide.enums.PairingStatus;
|
import com.walkguide.enums.PairingStatus;
|
||||||
|
import com.walkguide.exception.PairingException;
|
||||||
import com.walkguide.repository.*;
|
import com.walkguide.repository.*;
|
||||||
import com.walkguide.websocket.LocationBroadcaster;
|
import com.walkguide.websocket.LocationBroadcaster;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@ -232,6 +233,35 @@ class LocationServiceTest {
|
|||||||
assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257);
|
assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("getLocationHistoryForGuardian - harus ambil histori milik User yang dipair, bukan guardian")
|
||||||
|
void getLocationHistoryForGuardian_activePairing_shouldUsePairedUserId() {
|
||||||
|
Pageable pageable = PageRequest.of(0, 10);
|
||||||
|
Page<LocationHistory> page = new PageImpl<>(List.of(sampleLocation), pageable, 1);
|
||||||
|
|
||||||
|
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
|
||||||
|
.thenReturn(Optional.of(activePairing));
|
||||||
|
when(locationHistoryRepository.findByUserIdOrderByCreatedAtDesc(2L, pageable)).thenReturn(page);
|
||||||
|
|
||||||
|
Page<LocationResponse> result = locationService.getLocationHistoryForGuardian(1L, pageable);
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(1);
|
||||||
|
verify(locationHistoryRepository).findByUserIdOrderByCreatedAtDesc(2L, pageable);
|
||||||
|
verify(locationHistoryRepository, never()).findByUserIdOrderByCreatedAtDesc(1L, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("getLocationHistoryForGuardian - tanpa pairing aktif harus error pairing")
|
||||||
|
void getLocationHistoryForGuardian_noPairing_shouldThrow() {
|
||||||
|
Pageable pageable = PageRequest.of(0, 10);
|
||||||
|
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> locationService.getLocationHistoryForGuardian(1L, pageable))
|
||||||
|
.isInstanceOf(PairingException.class)
|
||||||
|
.hasMessageContaining("Guardian belum terhubung");
|
||||||
|
}
|
||||||
|
|
||||||
// ===== haversineMeters TESTS =====
|
// ===== haversineMeters TESTS =====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
import static org.assertj.core.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
@ -54,6 +55,8 @@ class PairingServiceTest {
|
|||||||
.role("ROLE_USER")
|
.role("ROLE_USER")
|
||||||
.displayName("User Test")
|
.displayName("User Test")
|
||||||
.uniqueUserId("ABC123DEF456")
|
.uniqueUserId("ABC123DEF456")
|
||||||
|
.pairingCode("AB12CD34")
|
||||||
|
.pairingCodeExpiresAt(LocalDateTime.now().plusMinutes(10))
|
||||||
.fcmToken("user-fcm-token")
|
.fcmToken("user-fcm-token")
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@ -66,7 +69,7 @@ class PairingServiceTest {
|
|||||||
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
|
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
|
||||||
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
|
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
|
||||||
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
|
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
|
||||||
when(userRepository.findByUniqueUserId("ABC123DEF456")).thenReturn(Optional.of(user));
|
when(userRepository.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
|
||||||
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(false);
|
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(false);
|
||||||
when(pairingRelationRepository.save(any(PairingRelation.class))).thenAnswer(inv -> {
|
when(pairingRelationRepository.save(any(PairingRelation.class))).thenAnswer(inv -> {
|
||||||
PairingRelation p = inv.getArgument(0);
|
PairingRelation p = inv.getArgument(0);
|
||||||
@ -74,7 +77,7 @@ class PairingServiceTest {
|
|||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
|
|
||||||
PairingStatusResponse result = pairingService.inviteUser(1L, "ABC123DEF456");
|
PairingStatusResponse result = pairingService.inviteUser(1L, "AB12CD34");
|
||||||
|
|
||||||
assertThat(result).isNotNull();
|
assertThat(result).isNotNull();
|
||||||
verify(pairingRelationRepository).save(any(PairingRelation.class));
|
verify(pairingRelationRepository).save(any(PairingRelation.class));
|
||||||
@ -113,7 +116,7 @@ class PairingServiceTest {
|
|||||||
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
|
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
|
||||||
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
|
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
|
||||||
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
|
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
|
||||||
when(userRepository.findByUniqueUserId("INVALID")).thenReturn(Optional.empty());
|
when(userRepository.findByPairingCode("INVALID")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> pairingService.inviteUser(1L, "INVALID"))
|
assertThatThrownBy(() -> pairingService.inviteUser(1L, "INVALID"))
|
||||||
.isInstanceOf(ResourceNotFoundException.class)
|
.isInstanceOf(ResourceNotFoundException.class)
|
||||||
@ -124,14 +127,17 @@ class PairingServiceTest {
|
|||||||
@DisplayName("inviteUser - target bukan ROLE_USER harus throw PairingException")
|
@DisplayName("inviteUser - target bukan ROLE_USER harus throw PairingException")
|
||||||
void inviteUser_targetNotUser_shouldThrow() {
|
void inviteUser_targetNotUser_shouldThrow() {
|
||||||
User anotherGuardian = User.builder()
|
User anotherGuardian = User.builder()
|
||||||
.id(3L).role("ROLE_GUARDIAN").uniqueUserId("GRD000000001").build();
|
.id(3L).role("ROLE_GUARDIAN")
|
||||||
|
.pairingCode("GRD00001")
|
||||||
|
.pairingCodeExpiresAt(LocalDateTime.now().plusMinutes(10))
|
||||||
|
.build();
|
||||||
|
|
||||||
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
|
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
|
||||||
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
|
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
|
||||||
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
|
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
|
||||||
when(userRepository.findByUniqueUserId("GRD000000001")).thenReturn(Optional.of(anotherGuardian));
|
when(userRepository.findByPairingCode("GRD00001")).thenReturn(Optional.of(anotherGuardian));
|
||||||
|
|
||||||
assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD000000001"))
|
assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD00001"))
|
||||||
.isInstanceOf(PairingException.class)
|
.isInstanceOf(PairingException.class)
|
||||||
.hasMessageContaining("bukan milik User");
|
.hasMessageContaining("bukan milik User");
|
||||||
}
|
}
|
||||||
@ -142,10 +148,10 @@ class PairingServiceTest {
|
|||||||
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
|
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
|
||||||
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
|
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
|
||||||
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
|
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
|
||||||
when(userRepository.findByUniqueUserId("ABC123DEF456")).thenReturn(Optional.of(user));
|
when(userRepository.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
|
||||||
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(true);
|
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(true);
|
||||||
|
|
||||||
assertThatThrownBy(() -> pairingService.inviteUser(1L, "ABC123DEF456"))
|
assertThatThrownBy(() -> pairingService.inviteUser(1L, "AB12CD34"))
|
||||||
.isInstanceOf(PairingException.class)
|
.isInstanceOf(PairingException.class)
|
||||||
.hasMessageContaining("sudah dipair dengan Guardian lain");
|
.hasMessageContaining("sudah dipair dengan Guardian lain");
|
||||||
}
|
}
|
||||||
|
|||||||
10
walkguide-mobile/walkguide_app/.gitignore
vendored
10
walkguide-mobile/walkguide_app/.gitignore
vendored
@ -49,3 +49,13 @@ hs_err_pid*.log
|
|||||||
|
|
||||||
# Android SDK path (generated by Android Studio)
|
# Android SDK path (generated by Android Studio)
|
||||||
android/local.properties
|
android/local.properties
|
||||||
|
|
||||||
|
# Local Python/YOLO export artifacts - do not commit
|
||||||
|
.venv*/
|
||||||
|
yolov8n.onnx
|
||||||
|
yolov8n.pt
|
||||||
|
yolov8n_saved_model/
|
||||||
|
calibration_image_sample_data_*.npy
|
||||||
|
devtools_options.yaml
|
||||||
|
android/app/src/main/java/dev/flutter/plugins/
|
||||||
|
android/app/src/main/java/io/flutter/plugins/
|
||||||
|
|||||||
@ -31,6 +31,10 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
androidResources {
|
||||||
|
noCompress += listOf("tflite", "lite")
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
|
|||||||
@ -1,10 +1,80 @@
|
|||||||
person
|
person
|
||||||
|
bicycle
|
||||||
car
|
car
|
||||||
motorcycle
|
motorcycle
|
||||||
bicycle
|
airplane
|
||||||
bus
|
bus
|
||||||
|
train
|
||||||
truck
|
truck
|
||||||
chair
|
boat
|
||||||
|
traffic light
|
||||||
|
fire hydrant
|
||||||
|
stop sign
|
||||||
|
parking meter
|
||||||
bench
|
bench
|
||||||
door
|
bird
|
||||||
stairs
|
cat
|
||||||
|
dog
|
||||||
|
horse
|
||||||
|
sheep
|
||||||
|
cow
|
||||||
|
elephant
|
||||||
|
bear
|
||||||
|
zebra
|
||||||
|
giraffe
|
||||||
|
backpack
|
||||||
|
umbrella
|
||||||
|
handbag
|
||||||
|
tie
|
||||||
|
suitcase
|
||||||
|
frisbee
|
||||||
|
skis
|
||||||
|
snowboard
|
||||||
|
sports ball
|
||||||
|
kite
|
||||||
|
baseball bat
|
||||||
|
baseball glove
|
||||||
|
skateboard
|
||||||
|
surfboard
|
||||||
|
tennis racket
|
||||||
|
bottle
|
||||||
|
wine glass
|
||||||
|
cup
|
||||||
|
fork
|
||||||
|
knife
|
||||||
|
spoon
|
||||||
|
bowl
|
||||||
|
banana
|
||||||
|
apple
|
||||||
|
sandwich
|
||||||
|
orange
|
||||||
|
broccoli
|
||||||
|
carrot
|
||||||
|
hot dog
|
||||||
|
pizza
|
||||||
|
donut
|
||||||
|
cake
|
||||||
|
chair
|
||||||
|
couch
|
||||||
|
potted plant
|
||||||
|
bed
|
||||||
|
dining table
|
||||||
|
toilet
|
||||||
|
tv
|
||||||
|
laptop
|
||||||
|
mouse
|
||||||
|
remote
|
||||||
|
keyboard
|
||||||
|
cell phone
|
||||||
|
microwave
|
||||||
|
oven
|
||||||
|
toaster
|
||||||
|
sink
|
||||||
|
refrigerator
|
||||||
|
book
|
||||||
|
clock
|
||||||
|
vase
|
||||||
|
scissors
|
||||||
|
teddy bear
|
||||||
|
hair drier
|
||||||
|
toothbrush
|
||||||
|
|||||||
@ -20,35 +20,79 @@ class WalkGuideApp extends StatelessWidget {
|
|||||||
routerConfig: appRouter,
|
routerConfig: appRouter,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: seed),
|
colorScheme: ColorScheme.fromSeed(
|
||||||
scaffoldBackgroundColor: const Color(0xFFF8FAFC),
|
seedColor: seed,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFFF4F7FB),
|
||||||
textTheme: GoogleFonts.interTextTheme(),
|
textTheme: GoogleFonts.interTextTheme(),
|
||||||
|
pageTransitionsTheme: const PageTransitionsTheme(
|
||||||
|
builders: {
|
||||||
|
TargetPlatform.android: ZoomPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
|
||||||
|
},
|
||||||
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Color(0xFFF4F7FB),
|
||||||
foregroundColor: Color(0xFF0F172A),
|
foregroundColor: Color(0xFF0F172A),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
surfaceTintColor: Colors.white,
|
surfaceTintColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
elevation: 0,
|
||||||
|
height: 76,
|
||||||
|
backgroundColor: Colors.white.withValues(alpha: 0.96),
|
||||||
|
indicatorColor: const Color(0xFFE0E7FF),
|
||||||
|
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: states.contains(WidgetState.selected)
|
||||||
|
? FontWeight.w800
|
||||||
|
: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: seed,
|
backgroundColor: seed,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
minimumSize: const Size(0, 46),
|
minimumSize: const Size(0, 50),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 50),
|
||||||
|
foregroundColor: seed,
|
||||||
|
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||||
|
side: const BorderSide(color: Color(0xFFCBD5E1)),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: const Color(0xFFF8FAFC),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||||
),
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: const BorderSide(color: seed, width: 1.5),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -2,8 +2,33 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../core/constants/app_constants.dart';
|
import '../core/constants/app_constants.dart';
|
||||||
import '../features/activity_log/activity_log_screen.dart' as activity;
|
import '../features/activity_log/activity_log_screen.dart' as activity;
|
||||||
|
import '../features/ai_benchmark/ai_benchmark_screen.dart' as benchmark;
|
||||||
|
import '../features/auth/login_screen.dart' as auth_login;
|
||||||
|
import '../features/auth/register_screen.dart' as auth_register;
|
||||||
|
import '../features/auth/splash_screen.dart' as auth_splash;
|
||||||
|
import '../features/call/call_screen.dart' as call;
|
||||||
|
import '../features/guardian_dashboard/guardian_activity_log_screen.dart'
|
||||||
|
as guardian_logs;
|
||||||
|
import '../features/guardian_dashboard/guardian_ai_config_screen.dart'
|
||||||
|
as guardian_ai;
|
||||||
|
import '../features/guardian_dashboard/guardian_map_screen.dart'
|
||||||
|
as guardian_map;
|
||||||
|
import '../features/guardian_dashboard/guardian_send_notification_screen.dart'
|
||||||
|
as guardian_send;
|
||||||
|
import '../features/guardian_dashboard/guardian_settings_screen.dart'
|
||||||
|
as guardian_settings;
|
||||||
|
import '../features/guardian_dashboard/guardian_tools_screen.dart'
|
||||||
|
as guardian_tools;
|
||||||
|
import '../features/home/presentation/guardian_dashboard_screen.dart'
|
||||||
|
as guardian_home;
|
||||||
|
import '../features/navigation_mode/navigation_mode_screen.dart' as nav;
|
||||||
import '../features/notifications/notification_screen.dart' as notifications;
|
import '../features/notifications/notification_screen.dart' as notifications;
|
||||||
import '../features/screens.dart';
|
import '../features/pairing/pairing_screens.dart' as pairing;
|
||||||
|
import '../features/server_connect/server_connect_server.dart'
|
||||||
|
as server_connect;
|
||||||
|
import '../features/settings/user_settings_screen.dart' as user_settings;
|
||||||
|
import '../features/sos/sos_screen.dart' as sos;
|
||||||
|
import '../features/walk_guide/walk_guide_screen.dart' as walk_guide;
|
||||||
import '../shared/widgets/app_shells.dart';
|
import '../shared/widgets/app_shells.dart';
|
||||||
|
|
||||||
final GoRouter appRouter = GoRouter(
|
final GoRouter appRouter = GoRouter(
|
||||||
@ -25,19 +50,23 @@ final GoRouter appRouter = GoRouter(
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/server-connect',
|
path: '/server-connect',
|
||||||
builder: (_, __) => const ServerConnectScreen()),
|
builder: (_, __) => const server_connect.ServerConnectScreen()),
|
||||||
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
|
|
||||||
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
|
||||||
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/incoming-call', builder: (_, __) => const IncomingCallScreen()),
|
path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
|
||||||
|
GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()),
|
||||||
|
GoRoute(
|
||||||
|
path: '/register',
|
||||||
|
builder: (_, __) => const auth_register.RegisterScreen()),
|
||||||
|
GoRoute(
|
||||||
|
path: '/incoming-call',
|
||||||
|
builder: (_, __) => const call.IncomingCallScreen()),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (_, __, child) => UserShell(child: child),
|
builder: (_, __, child) => UserShell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/walkguide',
|
path: '/user/walkguide',
|
||||||
builder: (_, __) => const WalkGuideScreen()),
|
builder: (_, __) => const walk_guide.WalkGuideScreen()),
|
||||||
GoRoute(path: '/user/sos', builder: (_, __) => const SosScreen()),
|
GoRoute(path: '/user/sos', builder: (_, __) => const sos.SosScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/activity',
|
path: '/user/activity',
|
||||||
builder: (_, __) => const activity.ActivityLogScreen()),
|
builder: (_, __) => const activity.ActivityLogScreen()),
|
||||||
@ -46,17 +75,18 @@ final GoRouter appRouter = GoRouter(
|
|||||||
builder: (_, __) => const notifications.NotificationScreen()),
|
builder: (_, __) => const notifications.NotificationScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/navigation',
|
path: '/user/navigation',
|
||||||
builder: (_, __) => const NavigationModeScreen()),
|
builder: (_, __) => const nav.NavigationModeScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/settings',
|
path: '/user/settings',
|
||||||
builder: (_, __) => const UserSettingsScreen()),
|
builder: (_, __) => const user_settings.UserSettingsScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/pairing',
|
path: '/user/pairing',
|
||||||
builder: (_, __) => const UserPairingScreen()),
|
builder: (_, __) => const pairing.UserPairingScreen()),
|
||||||
GoRoute(path: '/user/call', builder: (_, __) => const CallScreen()),
|
GoRoute(
|
||||||
|
path: '/user/call', builder: (_, __) => const call.CallScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/benchmark',
|
path: '/user/benchmark',
|
||||||
builder: (_, __) => const AiBenchmarkScreen()),
|
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
@ -64,37 +94,39 @@ final GoRouter appRouter = GoRouter(
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/dashboard',
|
path: '/guardian/dashboard',
|
||||||
builder: (_, __) => const GuardianDashboardScreen()),
|
builder: (_, __) => const guardian_home.GuardianDashboardScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/map',
|
path: '/guardian/map',
|
||||||
builder: (_, __) => const GuardianMapScreen()),
|
builder: (_, __) => const guardian_map.GuardianMapScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/logs',
|
path: '/guardian/logs',
|
||||||
builder: (_, __) => const GuardianActivityLogScreen()),
|
builder: (_, __) =>
|
||||||
|
const guardian_logs.GuardianActivityLogScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/send-notif',
|
path: '/guardian/send-notif',
|
||||||
builder: (_, __) => const GuardianSendNotifScreen()),
|
builder: (_, __) => const guardian_send.GuardianSendNotifScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/ai-config',
|
path: '/guardian/ai-config',
|
||||||
builder: (_, __) => const GuardianAiConfigScreen()),
|
builder: (_, __) => const guardian_ai.GuardianAiConfigScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/voice-cmd',
|
path: '/guardian/voice-cmd',
|
||||||
builder: (_, __) => const GuardianVoiceCmdScreen()),
|
builder: (_, __) => const guardian_tools.GuardianVoiceCmdScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/shortcuts',
|
path: '/guardian/shortcuts',
|
||||||
builder: (_, __) => const GuardianShortcutScreen()),
|
builder: (_, __) => const guardian_tools.GuardianShortcutScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/geofence',
|
path: '/guardian/geofence',
|
||||||
builder: (_, __) => const GuardianGeofenceScreen()),
|
builder: (_, __) => const guardian_tools.GuardianGeofenceScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/pairing',
|
path: '/guardian/pairing',
|
||||||
builder: (_, __) => const GuardianPairingScreen()),
|
builder: (_, __) => const pairing.GuardianPairingScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/settings',
|
path: '/guardian/settings',
|
||||||
builder: (_, __) => const GuardianSettingsScreen()),
|
builder: (_, __) =>
|
||||||
|
const guardian_settings.GuardianSettingsScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/benchmark',
|
path: '/guardian/benchmark',
|
||||||
builder: (_, __) => const AiBenchmarkScreen()),
|
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -23,12 +23,14 @@ class DetectionResult {
|
|||||||
final double confidence;
|
final double confidence;
|
||||||
final ObstacleDirection direction;
|
final ObstacleDirection direction;
|
||||||
final String estimatedDistance;
|
final String estimatedDistance;
|
||||||
|
final BoundingBox? box;
|
||||||
|
|
||||||
const DetectionResult({
|
const DetectionResult({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.confidence,
|
required this.confidence,
|
||||||
required this.direction,
|
required this.direction,
|
||||||
required this.estimatedDistance,
|
required this.estimatedDistance,
|
||||||
|
this.box,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get directionName => direction.name.toUpperCase();
|
String get directionName => direction.name.toUpperCase();
|
||||||
@ -39,7 +41,7 @@ class DetectionResult {
|
|||||||
ObstacleDirection.center => 'tengah',
|
ObstacleDirection.center => 'tengah',
|
||||||
ObstacleDirection.right => 'kanan',
|
ObstacleDirection.right => 'kanan',
|
||||||
};
|
};
|
||||||
return 'Hati-hati, $label di $area. Jarak $estimatedDistance.';
|
return 'Hati-hati, ${ObstacleAnalyzer.spokenLabel(label)} di $area. Jarak $estimatedDistance.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,7 +70,7 @@ class ObstacleAnalyzer {
|
|||||||
ObstacleDirection.center => 'depan',
|
ObstacleDirection.center => 'depan',
|
||||||
ObstacleDirection.right => 'kanan',
|
ObstacleDirection.right => 'kanan',
|
||||||
};
|
};
|
||||||
return 'Hati-hati, ${result.label} di $directionLabel. '
|
return 'Hati-hati, ${spokenLabel(result.label)} di $directionLabel. '
|
||||||
'Jarak ${result.estimatedDistance}.';
|
'Jarak ${result.estimatedDistance}.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,7 +92,12 @@ class ObstacleAnalyzer {
|
|||||||
final bi = order.indexOf(b.estimatedDistance);
|
final bi = order.indexOf(b.estimatedDistance);
|
||||||
final aRank = ai == -1 ? order.length : ai;
|
final aRank = ai == -1 ? order.length : ai;
|
||||||
final bRank = bi == -1 ? order.length : bi;
|
final bRank = bi == -1 ? order.length : bi;
|
||||||
return aRank.compareTo(bRank);
|
final distanceCompare = aRank.compareTo(bRank);
|
||||||
|
if (distanceCompare != 0) return distanceCompare;
|
||||||
|
final directionCompare =
|
||||||
|
_directionRisk(a.direction).compareTo(_directionRisk(b.direction));
|
||||||
|
if (directionCompare != 0) return directionCompare;
|
||||||
|
return b.confidence.compareTo(a.confidence);
|
||||||
});
|
});
|
||||||
return sorted.first;
|
return sorted.first;
|
||||||
}
|
}
|
||||||
@ -102,15 +109,43 @@ class ObstacleAnalyzer {
|
|||||||
return detections.where((d) => d.confidence >= threshold).toList();
|
return detections.where((d) => d.confidence >= threshold).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
DetectionResult analyzeFallback({
|
int _directionRisk(ObstacleDirection direction) {
|
||||||
String label = 'person',
|
return switch (direction) {
|
||||||
double confidence = 0.86,
|
ObstacleDirection.center => 0,
|
||||||
}) {
|
ObstacleDirection.left => 1,
|
||||||
return DetectionResult(
|
ObstacleDirection.right => 1,
|
||||||
label: label,
|
};
|
||||||
confidence: confidence,
|
}
|
||||||
direction: ObstacleDirection.center,
|
|
||||||
estimatedDistance: 'Close (1-2m)',
|
static String spokenLabel(String label) {
|
||||||
);
|
return switch (label.toLowerCase()) {
|
||||||
|
'person' => 'orang',
|
||||||
|
'bicycle' => 'sepeda',
|
||||||
|
'car' => 'mobil',
|
||||||
|
'motorcycle' => 'motor',
|
||||||
|
'airplane' => 'pesawat',
|
||||||
|
'bus' => 'bus',
|
||||||
|
'train' => 'kereta',
|
||||||
|
'truck' => 'truk',
|
||||||
|
'boat' => 'perahu',
|
||||||
|
'traffic light' => 'lampu lalu lintas',
|
||||||
|
'fire hydrant' => 'hidran',
|
||||||
|
'stop sign' => 'rambu berhenti',
|
||||||
|
'parking meter' => 'meter parkir',
|
||||||
|
'bench' => 'bangku',
|
||||||
|
'chair' => 'kursi',
|
||||||
|
'couch' => 'sofa',
|
||||||
|
'potted plant' => 'pot tanaman',
|
||||||
|
'dining table' => 'meja',
|
||||||
|
'tv' => 'televisi',
|
||||||
|
'backpack' => 'tas',
|
||||||
|
'umbrella' => 'payung',
|
||||||
|
'handbag' => 'tas tangan',
|
||||||
|
'suitcase' => 'koper',
|
||||||
|
'skateboard' => 'papan skate',
|
||||||
|
'surfboard' => 'papan selancar',
|
||||||
|
'sports ball' => 'bola',
|
||||||
|
_ => label,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,11 +18,31 @@ class YoloDetector {
|
|||||||
YoloTensorType? _outputType;
|
YoloTensorType? _outputType;
|
||||||
bool _ready = false;
|
bool _ready = false;
|
||||||
String? _lastError;
|
String? _lastError;
|
||||||
|
int _lastDecodedCount = 0;
|
||||||
|
int _lastConfidenceCount = 0;
|
||||||
|
int _lastObstacleCount = 0;
|
||||||
|
int _lastKeptCount = 0;
|
||||||
|
int _lastInferenceMs = 0;
|
||||||
|
String? _lastBestLabel;
|
||||||
|
double? _lastBestConfidence;
|
||||||
|
|
||||||
YoloDetector(this._analyzer);
|
YoloDetector(this._analyzer);
|
||||||
|
|
||||||
bool get isReady => _ready && _runtime != null;
|
bool get isReady => _ready && _runtime != null;
|
||||||
String? get lastError => _lastError;
|
String? get lastError => _lastError;
|
||||||
|
String get diagnosticsSummary {
|
||||||
|
if (!isReady) {
|
||||||
|
return _lastError == null
|
||||||
|
? 'YOLO runtime belum aktif'
|
||||||
|
: 'YOLO runtime error: $_lastError';
|
||||||
|
}
|
||||||
|
final bestLabel = _lastBestLabel;
|
||||||
|
final bestConfidence = _lastBestConfidence;
|
||||||
|
final bestText = bestLabel == null || bestConfidence == null
|
||||||
|
? 'best: none'
|
||||||
|
: 'best: $bestLabel ${(bestConfidence * 100).toStringAsFixed(0)}%';
|
||||||
|
return 'frames ok, decoded $_lastDecodedCount, >=threshold $_lastConfidenceCount, obstacle $_lastObstacleCount, kept $_lastKeptCount, $bestText, ${_lastInferenceMs}ms';
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
dispose();
|
dispose();
|
||||||
@ -47,55 +67,64 @@ class YoloDetector {
|
|||||||
_ready = true;
|
_ready = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_lastError = e.toString();
|
_lastError = e.toString();
|
||||||
debugPrint('YOLO fallback mode: $e');
|
debugPrint('YOLO runtime initialization skipped: $e');
|
||||||
_ready = false;
|
_ready = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DetectionResult?> detect(
|
Future<DetectionResult?> detect(
|
||||||
CameraImage image, {
|
CameraImage image, {
|
||||||
double confidenceThreshold = 0.45,
|
double confidenceThreshold = 0.25,
|
||||||
}) async {
|
}) async {
|
||||||
if (!isReady) return detectFallback();
|
if (!isReady) return null;
|
||||||
try {
|
try {
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
final input = _buildCameraInput(image);
|
final input = _buildCameraInput(image);
|
||||||
final output = Uint8List(_runtime!.outputByteLength);
|
final output = Uint8List(_runtime!.outputByteLength);
|
||||||
_runtime!.run(input, output);
|
_runtime!.run(input, output);
|
||||||
final detections = _decodeDetections(output);
|
final detections = _decodeDetections(output);
|
||||||
|
_recordDecoded(detections);
|
||||||
final filtered =
|
final filtered =
|
||||||
_analyzer.filterByConfidence(detections, confidenceThreshold);
|
_analyzer.filterByConfidence(detections, confidenceThreshold);
|
||||||
return _analyzer.prioritize(filtered);
|
final kept = _nonMaxSuppression(filtered);
|
||||||
|
_lastInferenceMs = stopwatch.elapsedMilliseconds;
|
||||||
|
_lastConfidenceCount = filtered.length;
|
||||||
|
_lastObstacleCount =
|
||||||
|
filtered.where((d) => _isObstacleLabel(d.label)).length;
|
||||||
|
_lastKeptCount = kept.length;
|
||||||
|
return _analyzer.prioritize(kept);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_lastError = e.toString();
|
_lastError = e.toString();
|
||||||
debugPrint('YOLO inference fallback: $e');
|
debugPrint('YOLO inference skipped: $e');
|
||||||
return detectFallback();
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DetectionResult?> detectSynthetic({
|
Future<DetectionResult?> detectSynthetic({
|
||||||
double confidenceThreshold = 0.25,
|
double confidenceThreshold = 0.25,
|
||||||
}) async {
|
}) async {
|
||||||
if (!isReady) return detectFallback();
|
if (!isReady) return null;
|
||||||
try {
|
try {
|
||||||
final input = _buildSyntheticInput();
|
final input = _buildSyntheticInput();
|
||||||
final output = Uint8List(_runtime!.outputByteLength);
|
final output = Uint8List(_runtime!.outputByteLength);
|
||||||
_runtime!.run(input, output);
|
_runtime!.run(input, output);
|
||||||
final detections = _decodeDetections(output);
|
final detections = _decodeDetections(output);
|
||||||
|
_recordDecoded(detections);
|
||||||
final filtered =
|
final filtered =
|
||||||
_analyzer.filterByConfidence(detections, confidenceThreshold);
|
_analyzer.filterByConfidence(detections, confidenceThreshold);
|
||||||
return _analyzer.prioritize(filtered) ?? detectFallback();
|
final kept = _nonMaxSuppression(filtered);
|
||||||
|
_lastConfidenceCount = filtered.length;
|
||||||
|
_lastObstacleCount =
|
||||||
|
filtered.where((d) => _isObstacleLabel(d.label)).length;
|
||||||
|
_lastKeptCount = kept.length;
|
||||||
|
return _analyzer.prioritize(kept);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_lastError = e.toString();
|
_lastError = e.toString();
|
||||||
debugPrint('YOLO synthetic fallback: $e');
|
debugPrint('YOLO synthetic skipped: $e');
|
||||||
return detectFallback();
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DetectionResult?> detectFallback() async {
|
|
||||||
final label = _labels.isNotEmpty ? _labels.first : 'person';
|
|
||||||
return _analyzer.analyzeFallback(label: label);
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_runtime?.close();
|
_runtime?.close();
|
||||||
_runtime = null;
|
_runtime = null;
|
||||||
@ -104,6 +133,26 @@ class YoloDetector {
|
|||||||
_inputType = null;
|
_inputType = null;
|
||||||
_outputType = null;
|
_outputType = null;
|
||||||
_ready = false;
|
_ready = false;
|
||||||
|
_lastDecodedCount = 0;
|
||||||
|
_lastConfidenceCount = 0;
|
||||||
|
_lastObstacleCount = 0;
|
||||||
|
_lastKeptCount = 0;
|
||||||
|
_lastInferenceMs = 0;
|
||||||
|
_lastBestLabel = null;
|
||||||
|
_lastBestConfidence = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recordDecoded(List<DetectionResult> detections) {
|
||||||
|
_lastDecodedCount = detections.length;
|
||||||
|
_lastBestLabel = null;
|
||||||
|
_lastBestConfidence = null;
|
||||||
|
for (final detection in detections) {
|
||||||
|
if (_lastBestConfidence == null ||
|
||||||
|
detection.confidence > _lastBestConfidence!) {
|
||||||
|
_lastBestLabel = detection.label;
|
||||||
|
_lastBestConfidence = detection.confidence;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Uint8List _buildCameraInput(CameraImage image) {
|
Uint8List _buildCameraInput(CameraImage image) {
|
||||||
@ -292,7 +341,7 @@ class YoloDetector {
|
|||||||
}
|
}
|
||||||
if (type == YoloTensorType.float16) {
|
if (type == YoloTensorType.float16) {
|
||||||
// Most exported YOLOv8 TFLite files use float32 output. Float16 is rare;
|
// Most exported YOLOv8 TFLite files use float32 output. Float16 is rare;
|
||||||
// fallback keeps the app usable if an unsupported variant is selected.
|
// Keep preprocessing explicit if an unsupported variant is selected.
|
||||||
throw StateError('Float16 YOLO output is not supported yet');
|
throw StateError('Float16 YOLO output is not supported yet');
|
||||||
}
|
}
|
||||||
throw StateError('Unsupported YOLO output type: $type');
|
throw StateError('Unsupported YOLO output type: $type');
|
||||||
@ -484,19 +533,164 @@ class YoloDetector {
|
|||||||
confidence: confidence,
|
confidence: confidence,
|
||||||
direction: _analyzer.analyzeDirection(box),
|
direction: _analyzer.analyzeDirection(box),
|
||||||
estimatedDistance: _analyzer.estimateDistance(box),
|
estimatedDistance: _analyzer.estimateDistance(box),
|
||||||
|
box: box,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<DetectionResult> _nonMaxSuppression(
|
||||||
|
List<DetectionResult> detections, {
|
||||||
|
double iouThreshold = 0.45,
|
||||||
|
int maxDetections = 8,
|
||||||
|
}) {
|
||||||
|
final candidates = detections
|
||||||
|
.where((d) => d.box != null && _isObstacleLabel(d.label))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.confidence.compareTo(a.confidence));
|
||||||
|
final selected = <DetectionResult>[];
|
||||||
|
|
||||||
|
for (final detection in candidates) {
|
||||||
|
final box = detection.box;
|
||||||
|
if (box == null) continue;
|
||||||
|
final overlaps = selected.any((kept) =>
|
||||||
|
kept.label == detection.label &&
|
||||||
|
kept.box != null &&
|
||||||
|
_iou(box, kept.box!) > iouThreshold);
|
||||||
|
if (overlaps) continue;
|
||||||
|
selected.add(detection);
|
||||||
|
if (selected.length >= maxDetections) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isObstacleLabel(String label) {
|
||||||
|
return _walkGuideObstacleLabels.contains(label.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
double _iou(BoundingBox a, BoundingBox b) {
|
||||||
|
final left = math.max(a.left, b.left);
|
||||||
|
final top = math.max(a.top, b.top);
|
||||||
|
final right = math.min(a.right, b.right);
|
||||||
|
final bottom = math.min(a.bottom, b.bottom);
|
||||||
|
final width = math.max(0.0, right - left);
|
||||||
|
final height = math.max(0.0, bottom - top);
|
||||||
|
final intersection = width * height;
|
||||||
|
final union = a.width * a.height + b.width * b.height - intersection;
|
||||||
|
if (union <= 0) return 0;
|
||||||
|
return intersection / union;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Set<String> _walkGuideObstacleLabels = {
|
||||||
|
'person',
|
||||||
|
'bicycle',
|
||||||
|
'car',
|
||||||
|
'motorcycle',
|
||||||
|
'bus',
|
||||||
|
'train',
|
||||||
|
'truck',
|
||||||
|
'traffic light',
|
||||||
|
'fire hydrant',
|
||||||
|
'stop sign',
|
||||||
|
'parking meter',
|
||||||
|
'bench',
|
||||||
|
'backpack',
|
||||||
|
'umbrella',
|
||||||
|
'handbag',
|
||||||
|
'suitcase',
|
||||||
|
'chair',
|
||||||
|
'couch',
|
||||||
|
'potted plant',
|
||||||
|
'dining table',
|
||||||
|
'tv',
|
||||||
|
'laptop',
|
||||||
|
'cell phone',
|
||||||
|
'bottle',
|
||||||
|
'cup',
|
||||||
|
'book',
|
||||||
|
};
|
||||||
|
|
||||||
const Map<int, String> _cocoObstacleLabels = {
|
const Map<int, String> _cocoObstacleLabels = {
|
||||||
0: 'person',
|
0: 'person',
|
||||||
1: 'bicycle',
|
1: 'bicycle',
|
||||||
2: 'car',
|
2: 'car',
|
||||||
3: 'motorcycle',
|
3: 'motorcycle',
|
||||||
|
4: 'airplane',
|
||||||
5: 'bus',
|
5: 'bus',
|
||||||
|
6: 'train',
|
||||||
7: 'truck',
|
7: 'truck',
|
||||||
|
8: 'boat',
|
||||||
|
9: 'traffic light',
|
||||||
|
10: 'fire hydrant',
|
||||||
|
11: 'stop sign',
|
||||||
|
12: 'parking meter',
|
||||||
13: 'bench',
|
13: 'bench',
|
||||||
|
14: 'bird',
|
||||||
|
15: 'cat',
|
||||||
|
16: 'dog',
|
||||||
|
17: 'horse',
|
||||||
|
18: 'sheep',
|
||||||
|
19: 'cow',
|
||||||
|
20: 'elephant',
|
||||||
|
21: 'bear',
|
||||||
|
22: 'zebra',
|
||||||
|
23: 'giraffe',
|
||||||
|
24: 'backpack',
|
||||||
|
25: 'umbrella',
|
||||||
|
26: 'handbag',
|
||||||
|
27: 'tie',
|
||||||
|
28: 'suitcase',
|
||||||
|
29: 'frisbee',
|
||||||
|
30: 'skis',
|
||||||
|
31: 'snowboard',
|
||||||
|
32: 'sports ball',
|
||||||
|
33: 'kite',
|
||||||
|
34: 'baseball bat',
|
||||||
|
35: 'baseball glove',
|
||||||
|
36: 'skateboard',
|
||||||
|
37: 'surfboard',
|
||||||
|
38: 'tennis racket',
|
||||||
|
39: 'bottle',
|
||||||
|
40: 'wine glass',
|
||||||
|
41: 'cup',
|
||||||
|
42: 'fork',
|
||||||
|
43: 'knife',
|
||||||
|
44: 'spoon',
|
||||||
|
45: 'bowl',
|
||||||
|
46: 'banana',
|
||||||
|
47: 'apple',
|
||||||
|
48: 'sandwich',
|
||||||
|
49: 'orange',
|
||||||
|
50: 'broccoli',
|
||||||
|
51: 'carrot',
|
||||||
|
52: 'hot dog',
|
||||||
|
53: 'pizza',
|
||||||
|
54: 'donut',
|
||||||
|
55: 'cake',
|
||||||
56: 'chair',
|
56: 'chair',
|
||||||
|
57: 'couch',
|
||||||
|
58: 'potted plant',
|
||||||
|
59: 'bed',
|
||||||
|
60: 'dining table',
|
||||||
|
61: 'toilet',
|
||||||
|
62: 'tv',
|
||||||
|
63: 'laptop',
|
||||||
|
64: 'mouse',
|
||||||
|
65: 'remote',
|
||||||
|
66: 'keyboard',
|
||||||
|
67: 'cell phone',
|
||||||
|
68: 'microwave',
|
||||||
|
69: 'oven',
|
||||||
|
70: 'toaster',
|
||||||
|
71: 'sink',
|
||||||
|
72: 'refrigerator',
|
||||||
|
73: 'book',
|
||||||
|
74: 'clock',
|
||||||
|
75: 'vase',
|
||||||
|
76: 'scissors',
|
||||||
|
77: 'teddy bear',
|
||||||
|
78: 'hair drier',
|
||||||
|
79: 'toothbrush',
|
||||||
};
|
};
|
||||||
|
|
||||||
class _InputInfo {
|
class _InputInfo {
|
||||||
|
|||||||
@ -1,21 +1,14 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart'; // Wajib import ini
|
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
static String get baseUrl {
|
static const baseUrl = String.fromEnvironment(
|
||||||
if (kIsWeb) {
|
'WALKGUIDE_API_BASE_URL',
|
||||||
// Jika di Chrome/Web, tembak localhost langsung
|
defaultValue: 'http://202.46.28.160:8080/api/v1',
|
||||||
return 'http://localhost:8080/api';
|
);
|
||||||
} else {
|
|
||||||
// Jika di Emulator Android, tembak IP khusus 10.0.2.2
|
|
||||||
return 'http://10.0.2.2:8080/api';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final Dio _dio = Dio(BaseOptions(
|
final Dio _dio = Dio(BaseOptions(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
connectTimeout: const Duration(seconds: 5),
|
connectTimeout: const Duration(seconds: 5),
|
||||||
// Penting buat Web agar tidak kena error CORS di sisi Client
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
|||||||
@ -0,0 +1,113 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
String friendlyErrorMessage(
|
||||||
|
Object error, {
|
||||||
|
required String fallback,
|
||||||
|
String? connectionHint,
|
||||||
|
}) {
|
||||||
|
if (error is DioException) {
|
||||||
|
return friendlyDioMessage(
|
||||||
|
error,
|
||||||
|
fallback: fallback,
|
||||||
|
connectionHint: connectionHint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
String friendlyDioMessage(
|
||||||
|
DioException error, {
|
||||||
|
required String fallback,
|
||||||
|
String? connectionHint,
|
||||||
|
}) {
|
||||||
|
final backendMessage = _cleanBackendMessage(error.response?.data);
|
||||||
|
if (backendMessage != null) return backendMessage;
|
||||||
|
|
||||||
|
final status = error.response?.statusCode;
|
||||||
|
if (status == 401) return 'Sesi sudah habis atau data login salah.';
|
||||||
|
if (status == 403) return 'Akun kamu belum punya akses ke fitur ini.';
|
||||||
|
if (status == 404) return 'Data belum ditemukan.';
|
||||||
|
if (status == 409) return 'Data sudah dipakai. Coba gunakan data lain.';
|
||||||
|
if (status != null && status >= 500) {
|
||||||
|
return 'Server sedang bermasalah. Coba lagi sebentar.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.type == DioExceptionType.connectionTimeout ||
|
||||||
|
error.type == DioExceptionType.sendTimeout ||
|
||||||
|
error.type == DioExceptionType.receiveTimeout) {
|
||||||
|
return 'Server terlalu lama merespons. Coba lagi.';
|
||||||
|
}
|
||||||
|
if (error.type == DioExceptionType.connectionError) {
|
||||||
|
return connectionHint ?? 'Tidak bisa terhubung ke server.';
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _cleanBackendMessage(Object? data) {
|
||||||
|
Object? raw;
|
||||||
|
if (data is Map) {
|
||||||
|
raw = data['message'] ?? data['error'] ?? data['errorCode'];
|
||||||
|
} else if (data is String) {
|
||||||
|
raw = data;
|
||||||
|
}
|
||||||
|
final message = raw?.toString().trim();
|
||||||
|
if (message == null || message.isEmpty || _looksTechnical(message)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _looksTechnical(String message) {
|
||||||
|
final lower = message.toLowerCase();
|
||||||
|
const blocked = [
|
||||||
|
'exception',
|
||||||
|
'dioexception',
|
||||||
|
'typeerror',
|
||||||
|
'stacktrace',
|
||||||
|
'instance of',
|
||||||
|
'_jsonmap',
|
||||||
|
'socketexception',
|
||||||
|
'package:',
|
||||||
|
'null check operator',
|
||||||
|
'nosuchmethod',
|
||||||
|
'formatexception',
|
||||||
|
];
|
||||||
|
return blocked.any(lower.contains);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T?> runFriendly<T>(
|
||||||
|
Future<T?> Function() action, {
|
||||||
|
required void Function(String message) onError,
|
||||||
|
required String fallback,
|
||||||
|
String? connectionHint,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await action();
|
||||||
|
} catch (error) {
|
||||||
|
onError(friendlyErrorMessage(
|
||||||
|
error,
|
||||||
|
fallback: fallback,
|
||||||
|
connectionHint: connectionHint,
|
||||||
|
));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> runFriendlyAction(
|
||||||
|
Future<void> Function() action, {
|
||||||
|
required void Function(String message) onError,
|
||||||
|
required String fallback,
|
||||||
|
String? connectionHint,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
onError(friendlyErrorMessage(
|
||||||
|
error,
|
||||||
|
fallback: fallback,
|
||||||
|
connectionHint: connectionHint,
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,7 +37,7 @@ class WebSocketService {
|
|||||||
bool get isConnected => _connected;
|
bool get isConnected => _connected;
|
||||||
|
|
||||||
/// Connect ke WebSocket server.
|
/// Connect ke WebSocket server.
|
||||||
/// Dipanggil setelah login berhasil (dari screens.dart _startPostLoginServices).
|
/// Dipanggil setelah login berhasil untuk membuka channel realtime.
|
||||||
Future<void> connect(String serverUrl) async {
|
Future<void> connect(String serverUrl) async {
|
||||||
await disconnect();
|
await disconnect();
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
@ -44,7 +45,8 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
|||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/user/activity-logs')
|
.get('/user/activity-logs')
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
@ -54,17 +56,12 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
|||||||
_items = items;
|
_items = items;
|
||||||
_applyFilter(_selectedFilter);
|
_applyFilter(_selectedFilter);
|
||||||
});
|
});
|
||||||
} on DioException catch (e) {
|
},
|
||||||
setState(() {
|
onError: (message) => setState(() => _error = message),
|
||||||
_error = e.response?.data?['message']?.toString() ??
|
fallback: 'Gagal memuat activity log. Coba refresh lagi.',
|
||||||
'Gagal memuat activity log.';
|
);
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() => _error = 'Timeout / error: $e');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
|
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
|
||||||
final data = responseBody is Map ? responseBody['data'] : null;
|
final data = responseBody is Map ? responseBody['data'] : null;
|
||||||
|
|||||||
@ -0,0 +1,290 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:camera/camera.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/ai/detection_export.dart';
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/services/tts_service.dart';
|
||||||
|
import '../../shared/widgets/feature_page.dart';
|
||||||
|
|
||||||
|
class AiBenchmarkScreen extends StatefulWidget {
|
||||||
|
const AiBenchmarkScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AiBenchmarkScreen> createState() => _AiBenchmarkScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
||||||
|
static const _runsKey = 'ai_benchmark_runs';
|
||||||
|
List<String> _models = const [];
|
||||||
|
String _selectedModel = AppConstants.yoloModelPath;
|
||||||
|
List<Map<String, dynamic>> _runs = const [];
|
||||||
|
bool _running = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final models = await _discoverTfliteModels();
|
||||||
|
final selected = await AppConstants.getSelectedYoloModelPath();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final rawRuns = prefs.getStringList(_runsKey) ?? const [];
|
||||||
|
setState(() {
|
||||||
|
_models = models.isEmpty ? [selected] : models;
|
||||||
|
_selectedModel = _models.contains(selected) ? selected : _models.first;
|
||||||
|
_runs = rawRuns
|
||||||
|
.map((e) => Map<String, dynamic>.from(jsonDecode(e) as Map))
|
||||||
|
.toList()
|
||||||
|
.reversed
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setModel(String? value) async {
|
||||||
|
if (value == null) return;
|
||||||
|
await AppConstants.setSelectedYoloModelPath(value);
|
||||||
|
sl<YoloDetector>().dispose();
|
||||||
|
await sl<YoloDetector>().init();
|
||||||
|
setState(() => _selectedModel = value);
|
||||||
|
_snack('Model aktif: ${value.split('/').last}');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runBenchmark() async {
|
||||||
|
setState(() => _running = true);
|
||||||
|
final started = DateTime.now();
|
||||||
|
final captureMs = await _measureCapture();
|
||||||
|
|
||||||
|
final inferenceWatch = Stopwatch()..start();
|
||||||
|
final modelLoaded = sl<YoloDetector>().isReady;
|
||||||
|
final detection = await sl<YoloDetector>().detectSynthetic();
|
||||||
|
inferenceWatch.stop();
|
||||||
|
|
||||||
|
final label = detection?.label ?? 'no detection';
|
||||||
|
final direction = detection?.directionName ?? '-';
|
||||||
|
final distance = detection?.estimatedDistance ?? '-';
|
||||||
|
final text = detection == null
|
||||||
|
? 'Tidak ada obstacle di atas threshold.'
|
||||||
|
: 'Obstacle $label di $direction, jarak $distance';
|
||||||
|
|
||||||
|
final notifWatch = Stopwatch()..start();
|
||||||
|
final notificationText = text;
|
||||||
|
notifWatch.stop();
|
||||||
|
|
||||||
|
final ttsWatch = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
await sl<TtsService>()
|
||||||
|
.speakImmediate(notificationText)
|
||||||
|
.timeout(const Duration(seconds: 3));
|
||||||
|
} catch (_) {}
|
||||||
|
ttsWatch.stop();
|
||||||
|
|
||||||
|
final run = {
|
||||||
|
'time': started.toIso8601String(),
|
||||||
|
'model': _selectedModel,
|
||||||
|
'modelLoaded': modelLoaded,
|
||||||
|
'captureMs': captureMs,
|
||||||
|
'inferenceMs': inferenceWatch.elapsedMilliseconds,
|
||||||
|
'notificationMs': notifWatch.elapsedMicroseconds / 1000,
|
||||||
|
'ttsMs': ttsWatch.elapsedMilliseconds,
|
||||||
|
'label': label,
|
||||||
|
'direction': direction,
|
||||||
|
};
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final next = [
|
||||||
|
jsonEncode(run),
|
||||||
|
...((prefs.getStringList(_runsKey) ?? const []).take(24)),
|
||||||
|
];
|
||||||
|
await prefs.setStringList(_runsKey, next);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_runs = [run, ..._runs].take(25).toList();
|
||||||
|
_running = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _measureCapture() async {
|
||||||
|
final watch = Stopwatch()..start();
|
||||||
|
CameraController? controller;
|
||||||
|
try {
|
||||||
|
final cameras =
|
||||||
|
await availableCameras().timeout(const Duration(seconds: 3));
|
||||||
|
if (cameras.isNotEmpty) {
|
||||||
|
controller = CameraController(
|
||||||
|
cameras.first,
|
||||||
|
ResolutionPreset.low,
|
||||||
|
enableAudio: false,
|
||||||
|
);
|
||||||
|
await controller.initialize().timeout(const Duration(seconds: 5));
|
||||||
|
await controller.takePicture().timeout(const Duration(seconds: 5));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 16));
|
||||||
|
} finally {
|
||||||
|
await controller?.dispose();
|
||||||
|
}
|
||||||
|
watch.stop();
|
||||||
|
return watch.elapsedMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearRuns() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_runsKey);
|
||||||
|
setState(() => _runs = const []);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _snack(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasRealModel = _models.any((model) => model.endsWith('.tflite'));
|
||||||
|
return FeaturePage(
|
||||||
|
title: 'AI Benchmark',
|
||||||
|
subtitle: 'Capture, inference, notification text, and TTS timing',
|
||||||
|
child: ListView(
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _selectedModel,
|
||||||
|
decoration: const InputDecoration(labelText: 'Model file'),
|
||||||
|
items: [
|
||||||
|
for (final model in _models)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: model,
|
||||||
|
child: Text(model.split('/').last),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: _setModel,
|
||||||
|
),
|
||||||
|
if (!hasRealModel) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const _StatusBox(
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
'Belum ada file .tflite di assets/models. Tambahkan model lalu restart app.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _running ? null : _runBenchmark,
|
||||||
|
icon: _running
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.play_arrow),
|
||||||
|
label: Text(_running ? 'Running benchmark...' : 'Run benchmark'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _clearRuns,
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: const Text('Clear log'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
for (final run in _runs) _BenchmarkCard(run: run),
|
||||||
|
if (_runs.isEmpty)
|
||||||
|
const FeatureEmptyPanel(
|
||||||
|
icon: Icons.speed,
|
||||||
|
title: 'Belum ada log',
|
||||||
|
message:
|
||||||
|
'Klik Run benchmark untuk mencatat capture, inference, notification, dan TTS.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BenchmarkCard extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> run;
|
||||||
|
|
||||||
|
const _BenchmarkCard({required this.run});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final time = DateTime.tryParse(run['time']?.toString() ?? '')?.toLocal();
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
time == null
|
||||||
|
? 'Benchmark run'
|
||||||
|
: '${_two(time.hour)}:${_two(time.minute)}:${_two(time.second)}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Model: ${run['model'].toString().split('/').last} (${run['modelLoaded'] == true ? 'loaded' : 'not ready'})'),
|
||||||
|
Text('Capture: ${run['captureMs']} ms'),
|
||||||
|
Text('Model/inference: ${run['inferenceMs']} ms'),
|
||||||
|
Text('Notification text: ${run['notificationMs']} ms'),
|
||||||
|
Text('TTS start: ${run['ttsMs']} ms'),
|
||||||
|
Text('Result: ${run['label']} ${run['direction']}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusBox extends StatelessWidget {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const _StatusBox({required this.success, required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(
|
||||||
|
color: success ? const Color(0xFF166534) : const Color(0xFF991B1B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> _discoverTfliteModels() async {
|
||||||
|
try {
|
||||||
|
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
|
||||||
|
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
|
||||||
|
final models = manifest.keys
|
||||||
|
.where((key) =>
|
||||||
|
key.startsWith('assets/models/') && key.endsWith('.tflite'))
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
return models;
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _two(int value) => value.toString().padLeft(2, '0');
|
||||||
@ -12,7 +12,8 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
AuthRepositoryImpl(this.remoteDataSource, this.secureStorage);
|
AuthRepositoryImpl(this.remoteDataSource, this.secureStorage);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Either<Failure, UserEntity>> login(String email, String password) async {
|
Future<Either<Failure, UserEntity>> login(
|
||||||
|
String email, String password) async {
|
||||||
try {
|
try {
|
||||||
// 1. Suruh data source nembak API
|
// 1. Suruh data source nembak API
|
||||||
final userModel = await remoteDataSource.login(email, password);
|
final userModel = await remoteDataSource.login(email, password);
|
||||||
@ -24,7 +25,21 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
return Right(userModel);
|
return Right(userModel);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 4. Balikin pesan error (Left)
|
// 4. Balikin pesan error (Left)
|
||||||
return Left(ServerFailure(e.toString().replaceAll('Exception: ', '')));
|
return Left(ServerFailure(_safeAuthFailure(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _safeAuthFailure(Object error) {
|
||||||
|
final message = error.toString().replaceFirst(RegExp(r'^\w+:\s*'), '').trim();
|
||||||
|
final lower = message.toLowerCase();
|
||||||
|
final technical = lower.contains('exception') ||
|
||||||
|
lower.contains('typeerror') ||
|
||||||
|
lower.contains('stacktrace') ||
|
||||||
|
lower.contains('instance of') ||
|
||||||
|
lower.contains('package:');
|
||||||
|
if (message.isEmpty || technical) {
|
||||||
|
return 'Login gagal. Periksa email dan password kamu.';
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@ -11,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import '../../app/app_cubit.dart';
|
import '../../app/app_cubit.dart';
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/services/offline_queue_service.dart';
|
import '../../core/services/offline_queue_service.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
@ -65,27 +65,27 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final res = await sl<ApiClient>().dio.post('/auth/login', data: {
|
final res = await sl<ApiClient>().dio.post('/auth/login', data: {
|
||||||
'email': _email.text.trim(),
|
'email': _email.text.trim(),
|
||||||
'password': _password.text,
|
'password': _password.text,
|
||||||
});
|
});
|
||||||
await _saveAuthAndRoute(
|
await _saveAuthAndRoute(
|
||||||
context, Map<String, dynamic>.from(res.data['data'] as Map));
|
context, Map<String, dynamic>.from(res.data['data'] as Map));
|
||||||
} on DioException catch (e) {
|
},
|
||||||
_snack(context, _friendlyDioMessage(e, fallback: 'Login gagal'));
|
onError: (message) => _snack(context, message),
|
||||||
} catch (e) {
|
fallback: 'Login gagal. Periksa email dan password kamu.',
|
||||||
_snack(context, 'Login gagal: $e');
|
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
|
||||||
} finally {
|
);
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _AuthFrame(
|
return _AuthFrame(
|
||||||
title: 'Sign in',
|
title: 'Sign in',
|
||||||
subtitle: 'Masuk sebagai Guardian atau User.',
|
subtitle: 'Masuk ke navigasi asistif realtime WalkGuide.',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@ -147,35 +147,123 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Center(
|
backgroundColor: const Color(0xFFEAF4FF),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
const Positioned.fill(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -90,
|
||||||
|
right: -60,
|
||||||
|
child: TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 0.85, end: 1),
|
||||||
|
duration: const Duration(milliseconds: 900),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (_, value, child) => Transform.scale(
|
||||||
|
scale: value,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
width: 260,
|
||||||
|
height: 260,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 460),
|
constraints: const BoxConstraints(maxWidth: 430),
|
||||||
child: Card(
|
child: TweenAnimationBuilder<double>(
|
||||||
elevation: 0,
|
tween: Tween(begin: 18, end: 0),
|
||||||
shape: RoundedRectangleBorder(
|
duration: const Duration(milliseconds: 520),
|
||||||
borderRadius: BorderRadius.circular(18),
|
curve: Curves.easeOutCubic,
|
||||||
side: const BorderSide(color: Color(0xFFE2E8F0))),
|
builder: (_, offset, child) => Opacity(
|
||||||
|
opacity: (1 - offset / 18).clamp(0.0, 1.0),
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, offset),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: RepaintBoundary(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.96),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.8)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color:
|
||||||
|
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
|
||||||
|
blurRadius: 40,
|
||||||
|
offset: const Offset(0, 20),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.navigation_rounded,
|
Row(
|
||||||
color: Color(0xFF1A56DB), size: 42),
|
children: [
|
||||||
const SizedBox(height: 14),
|
Container(
|
||||||
Text(title,
|
width: 56,
|
||||||
textAlign: TextAlign.center,
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1D4ED8),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.navigation_rounded,
|
||||||
|
color: Colors.white, size: 30),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'WalkGuide',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Color(0xFF0F172A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.headlineSmall
|
.headlineMedium
|
||||||
?.copyWith(fontWeight: FontWeight.w800)),
|
?.copyWith(
|
||||||
const SizedBox(height: 4),
|
fontWeight: FontWeight.w900,
|
||||||
Text(subtitle,
|
color: const Color(0xFF0F172A),
|
||||||
textAlign: TextAlign.center,
|
),
|
||||||
style: const TextStyle(color: Color(0xFF64748B))),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF64748B),
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -184,6 +272,10 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -219,16 +311,14 @@ Future<void> _saveAuthAndRoute(
|
|||||||
|
|
||||||
void _startPostLoginServices(String serverUrl) {
|
void _startPostLoginServices(String serverUrl) {
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
try {
|
|
||||||
await sl<WebSocketService>()
|
await sl<WebSocketService>()
|
||||||
.connect(serverUrl)
|
.connect(serverUrl)
|
||||||
.timeout(const Duration(seconds: 2));
|
.timeout(const Duration(seconds: 2));
|
||||||
await sl<OfflineQueueService>()
|
await sl<OfflineQueueService>()
|
||||||
.syncPending(sl<ApiClient>())
|
.syncPending(sl<ApiClient>())
|
||||||
.timeout(const Duration(seconds: 3));
|
.timeout(const Duration(seconds: 3));
|
||||||
} catch (e) {
|
}).catchError((Object e) {
|
||||||
debugPrint('Post-login services skipped: $e');
|
debugPrint('Post-login services skipped: $e');
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,19 +328,3 @@ void _snack(BuildContext context, String message) {
|
|||||||
.showSnackBar(SnackBar(content: Text(message)));
|
.showSnackBar(SnackBar(content: Text(message)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _friendlyDioMessage(DioException e, {required String fallback}) {
|
|
||||||
final data = e.response?.data;
|
|
||||||
if (data is Map && data['message'] != null) return data['message'].toString();
|
|
||||||
if (e.response?.statusCode == 401) {
|
|
||||||
return 'Email atau password salah.';
|
|
||||||
}
|
|
||||||
if (e.type == DioExceptionType.connectionTimeout ||
|
|
||||||
e.type == DioExceptionType.receiveTimeout) {
|
|
||||||
return 'Server terlalu lama merespons. Pastikan backend masih running dan URL server benar.';
|
|
||||||
}
|
|
||||||
if (e.type == DioExceptionType.connectionError) {
|
|
||||||
return 'Tidak bisa ke server. Di Chrome pakai http://localhost:8080. Di HP pakai IP laptop/server, bukan localhost.';
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export '../../screens.dart';
|
export '../login_screen.dart';
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
// ignore_for_file: use_build_context_synchronously
|
// ignore_for_file: use_build_context_synchronously
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -52,7 +52,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final res = await sl<ApiClient>().dio.post('/auth/register', data: {
|
final res = await sl<ApiClient>().dio.post('/auth/register', data: {
|
||||||
'displayName': _name.text.trim(),
|
'displayName': _name.text.trim(),
|
||||||
'email': _email.text.trim(),
|
'email': _email.text.trim(),
|
||||||
@ -65,14 +66,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
await prefs.setString('pending_login_email', _email.text.trim());
|
await prefs.setString('pending_login_email', _email.text.trim());
|
||||||
await _showRegisterSuccess(context, data);
|
await _showRegisterSuccess(context, data);
|
||||||
if (mounted) context.go('/login');
|
if (mounted) context.go('/login');
|
||||||
} on DioException catch (e) {
|
},
|
||||||
_snack(context, _friendlyDioMessage(e, fallback: 'Registrasi gagal'));
|
onError: (message) => _snack(context, message),
|
||||||
} catch (e) {
|
fallback: 'Registrasi gagal. Periksa data akun kamu.',
|
||||||
_snack(context, 'Registrasi gagal: $e');
|
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
|
||||||
} finally {
|
);
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -81,7 +81,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
subtitle: _step == 0
|
subtitle: _step == 0
|
||||||
? 'Who are you in the WalkGuide system?'
|
? 'Who are you in the WalkGuide system?'
|
||||||
: _role == 'USER'
|
: _role == 'USER'
|
||||||
? 'User akan mendapat Unique ID untuk pairing.'
|
? 'User bisa membuat Pairing Code sementara setelah login.'
|
||||||
: 'Guardian dapat monitor dan konfigurasi User.',
|
: 'Guardian dapat monitor dan konfigurasi User.',
|
||||||
child: _step == 0 ? _buildRoleStep(context) : _buildFormStep(context),
|
child: _step == 0 ? _buildRoleStep(context) : _buildFormStep(context),
|
||||||
);
|
);
|
||||||
@ -298,35 +298,123 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Center(
|
backgroundColor: const Color(0xFFEAF4FF),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
const Positioned.fill(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -90,
|
||||||
|
right: -60,
|
||||||
|
child: TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 0.85, end: 1),
|
||||||
|
duration: const Duration(milliseconds: 900),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (_, value, child) => Transform.scale(
|
||||||
|
scale: value,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
width: 260,
|
||||||
|
height: 260,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 460),
|
constraints: const BoxConstraints(maxWidth: 430),
|
||||||
child: Card(
|
child: TweenAnimationBuilder<double>(
|
||||||
elevation: 0,
|
tween: Tween(begin: 18, end: 0),
|
||||||
shape: RoundedRectangleBorder(
|
duration: const Duration(milliseconds: 520),
|
||||||
borderRadius: BorderRadius.circular(18),
|
curve: Curves.easeOutCubic,
|
||||||
side: const BorderSide(color: Color(0xFFE2E8F0))),
|
builder: (_, offset, child) => Opacity(
|
||||||
|
opacity: (1 - offset / 18).clamp(0.0, 1.0),
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, offset),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: RepaintBoundary(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.96),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.8)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color:
|
||||||
|
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
|
||||||
|
blurRadius: 40,
|
||||||
|
offset: const Offset(0, 20),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.navigation_rounded,
|
Row(
|
||||||
color: Color(0xFF1A56DB), size: 42),
|
children: [
|
||||||
const SizedBox(height: 14),
|
Container(
|
||||||
Text(title,
|
width: 56,
|
||||||
textAlign: TextAlign.center,
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1D4ED8),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.navigation_rounded,
|
||||||
|
color: Colors.white, size: 30),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'WalkGuide',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Color(0xFF0F172A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.headlineSmall
|
.headlineMedium
|
||||||
?.copyWith(fontWeight: FontWeight.w800)),
|
?.copyWith(
|
||||||
const SizedBox(height: 4),
|
fontWeight: FontWeight.w900,
|
||||||
Text(subtitle,
|
color: const Color(0xFF0F172A),
|
||||||
textAlign: TextAlign.center,
|
),
|
||||||
style: const TextStyle(color: Color(0xFF64748B))),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF64748B),
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -335,6 +423,10 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -348,12 +440,12 @@ Future<void> _showRegisterSuccess(
|
|||||||
final uniqueId = data['uniqueUserId']?.toString();
|
final uniqueId = data['uniqueUserId']?.toString();
|
||||||
final message = uniqueId == null || uniqueId.isEmpty
|
final message = uniqueId == null || uniqueId.isEmpty
|
||||||
? 'Registrasi berhasil. Silakan login.'
|
? 'Registrasi berhasil. Silakan login.'
|
||||||
: 'Registrasi berhasil!\n\nUnique User ID kamu:\n$uniqueId\n\nBagikan ID ini ke Guardian untuk pairing. Silakan login.';
|
: 'Registrasi berhasil.\n\nSilakan login, lalu buka menu Pairing Code untuk membuat kode sementara yang bisa dibagikan ke Guardian.';
|
||||||
_snack(
|
_snack(
|
||||||
context,
|
context,
|
||||||
uniqueId == null
|
uniqueId == null
|
||||||
? 'Registrasi berhasil.'
|
? 'Registrasi berhasil.'
|
||||||
: 'Registrasi berhasil. ID: $uniqueId');
|
: 'Registrasi berhasil. Buat Pairing Code setelah login.');
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
@ -375,19 +467,3 @@ void _snack(BuildContext context, String message) {
|
|||||||
.showSnackBar(SnackBar(content: Text(message)));
|
.showSnackBar(SnackBar(content: Text(message)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _friendlyDioMessage(DioException e, {required String fallback}) {
|
|
||||||
final data = e.response?.data;
|
|
||||||
if (data is Map && data['message'] != null) return data['message'].toString();
|
|
||||||
if (e.response?.statusCode == 409) {
|
|
||||||
return 'Email sudah terdaftar. Gunakan email lain atau login.';
|
|
||||||
}
|
|
||||||
if (e.type == DioExceptionType.connectionTimeout ||
|
|
||||||
e.type == DioExceptionType.receiveTimeout) {
|
|
||||||
return 'Server terlalu lama merespons. Pastikan backend masih running.';
|
|
||||||
}
|
|
||||||
if (e.type == DioExceptionType.connectionError) {
|
|
||||||
return 'Tidak bisa ke server. Di HP pakai IP server, bukan localhost.';
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/storage/secure_storage.dart';
|
import '../../core/storage/secure_storage.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -51,7 +52,8 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _route() async {
|
Future<void> _route() async {
|
||||||
try {
|
final routed = await runFriendlyAction(
|
||||||
|
() async {
|
||||||
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
|
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
@ -69,11 +71,14 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-login: arahkan ke home sesuai role.
|
// Auto-login: arahkan ke home sesuai role.
|
||||||
context.go(
|
context.go(role == 'ROLE_GUARDIAN'
|
||||||
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide');
|
? '/guardian/dashboard'
|
||||||
} catch (_) {
|
: '/user/walkguide');
|
||||||
if (mounted) context.go('/login');
|
},
|
||||||
}
|
onError: (_) {},
|
||||||
|
fallback: 'Sesi belum bisa dipulihkan.',
|
||||||
|
);
|
||||||
|
if (!routed && mounted) context.go('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../app/injection_container.dart';
|
import '../../../app/injection_container.dart';
|
||||||
|
import '../../../core/errors/friendly_error.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
|
|
||||||
Dio get _api => sl<ApiClient>().dio;
|
Dio get _api => sl<ApiClient>().dio;
|
||||||
@ -48,7 +49,8 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
_needsPairing = false;
|
_needsPairing = false;
|
||||||
});
|
});
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
// Cek pairing dulu
|
// Cek pairing dulu
|
||||||
final paired = await _hasActivePairing();
|
final paired = await _hasActivePairing();
|
||||||
if (!paired) {
|
if (!paired) {
|
||||||
@ -85,29 +87,29 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
|||||||
_applyFilter(_selectedFilter);
|
_applyFilter(_selectedFilter);
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
} on DioException catch (e) {
|
},
|
||||||
setState(() {
|
onError: (message) => setState(() {
|
||||||
_error = e.response?.data?['message']?.toString() ??
|
_error = message;
|
||||||
'Gagal memuat activity log.';
|
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
}),
|
||||||
} catch (e) {
|
fallback: 'Gagal memuat activity log. Coba refresh lagi.',
|
||||||
setState(() {
|
);
|
||||||
_error = 'Timeout / error: $e';
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _hasActivePairing() async {
|
Future<bool> _hasActivePairing() async {
|
||||||
try {
|
final active = await runFriendly<bool>(
|
||||||
|
() async {
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/shared/pairing/status')
|
.get('/shared/pairing/status')
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
final data = res.data['data'];
|
final data = res.data['data'];
|
||||||
if (data is Map) return data['status'] == 'ACTIVE';
|
if (data is Map) return data['status'] == 'ACTIVE';
|
||||||
} catch (_) {}
|
|
||||||
return false;
|
return false;
|
||||||
|
},
|
||||||
|
onError: (_) {},
|
||||||
|
fallback: 'Status pairing belum bisa dicek.',
|
||||||
|
);
|
||||||
|
return active ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyFilter(String filter) {
|
void _applyFilter(String filter) {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import '../../../app/injection_container.dart';
|
import '../../../app/injection_container.dart';
|
||||||
|
import '../../../core/errors/friendly_error.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
|
|
||||||
Dio get _api => sl<ApiClient>().dio;
|
Dio get _api => sl<ApiClient>().dio;
|
||||||
@ -73,11 +74,12 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
}
|
}
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e.response?.data?['message']?.toString() ??
|
_error =
|
||||||
'Gagal memuat konfigurasi AI.';
|
friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.');
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _error = 'Timeout / error: $e');
|
setState(
|
||||||
|
() => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.');
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
@ -105,8 +107,8 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(e.response?.data?['message']?.toString() ??
|
content: Text(friendlyDioMessage(e,
|
||||||
'Gagal menyimpan konfigurasi.'),
|
fallback: 'Gagal menyimpan konfigurasi.')),
|
||||||
backgroundColor: const Color(0xFFDC2626),
|
backgroundColor: const Color(0xFFDC2626),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -114,9 +116,9 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Text('Error: $e'),
|
content: Text('Gagal menyimpan konfigurasi. Coba lagi.'),
|
||||||
backgroundColor: const Color(0xFFDC2626),
|
backgroundColor: Color(0xFFDC2626),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,301 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
|
import '../../core/network/api_client.dart';
|
||||||
|
import '../../shared/widgets/feature_page.dart';
|
||||||
|
|
||||||
|
class GuardianMapScreen extends StatefulWidget {
|
||||||
|
const GuardianMapScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GuardianMapScreen> createState() => _GuardianMapScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GuardianMapScreenState extends State<GuardianMapScreen> {
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
Map<String, dynamic>? _lastLocation;
|
||||||
|
List<Map<String, dynamic>> _history = const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
|
final dio = sl<ApiClient>().dio;
|
||||||
|
final current = await dio
|
||||||
|
.get('/guardian/user-location')
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
final history = await dio.get('/guardian/location-history',
|
||||||
|
queryParameters: {'size': 80}).timeout(const Duration(seconds: 8));
|
||||||
|
final currentData = current.data is Map ? current.data['data'] : null;
|
||||||
|
final historyData = history.data is Map ? history.data['data'] : null;
|
||||||
|
_lastLocation =
|
||||||
|
currentData is Map ? Map<String, dynamic>.from(currentData) : null;
|
||||||
|
final content = historyData is Map ? historyData['content'] : null;
|
||||||
|
_history = content is List
|
||||||
|
? content
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => Map<String, dynamic>.from(e))
|
||||||
|
.toList()
|
||||||
|
: const [];
|
||||||
|
},
|
||||||
|
onError: (message) => _error = message,
|
||||||
|
fallback: 'Lokasi User belum bisa dimuat. Coba refresh lagi.',
|
||||||
|
);
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FeaturePage(
|
||||||
|
title: 'Live Map',
|
||||||
|
subtitle: 'Lokasi terakhir User dan timeline perjalanan',
|
||||||
|
trailing: IconButton(
|
||||||
|
onPressed: _loading ? null : _load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
),
|
||||||
|
child: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null
|
||||||
|
? FeatureErrorPanel(message: _error!, onRetry: _load)
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: _GuardianMapCard(
|
||||||
|
location: _lastLocation,
|
||||||
|
history: _history,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _TimelineList(history: _history, onRetry: _load),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GuardianMapCard extends StatelessWidget {
|
||||||
|
final Map<String, dynamic>? location;
|
||||||
|
final List<Map<String, dynamic>> history;
|
||||||
|
|
||||||
|
const _GuardianMapCard({required this.location, required this.history});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final points = _pointsFrom(history);
|
||||||
|
final center = _pointFrom(location) ??
|
||||||
|
(points.isNotEmpty ? points.first : null) ??
|
||||||
|
const LatLng(-7.2575, 112.7521);
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: FlutterMap(
|
||||||
|
options: MapOptions(initialCenter: center, initialZoom: 16),
|
||||||
|
children: [
|
||||||
|
TileLayer(
|
||||||
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.walkguide.app',
|
||||||
|
),
|
||||||
|
if (points.length > 1)
|
||||||
|
PolylineLayer(
|
||||||
|
polylines: [
|
||||||
|
Polyline(
|
||||||
|
points: points,
|
||||||
|
strokeWidth: 4,
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
Marker(
|
||||||
|
point: center,
|
||||||
|
width: 54,
|
||||||
|
height: 54,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 4),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
const Icon(Icons.person_pin_circle, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimelineList extends StatelessWidget {
|
||||||
|
final List<Map<String, dynamic>> history;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
const _TimelineList({required this.history, required this.onRetry});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final segments = _segments(history);
|
||||||
|
if (segments.isEmpty) {
|
||||||
|
return FeatureEmptyPanel(
|
||||||
|
icon: Icons.timeline,
|
||||||
|
title: 'Belum ada timeline',
|
||||||
|
message:
|
||||||
|
'Timeline akan muncul setelah User mengirim beberapa titik lokasi.',
|
||||||
|
action: OutlinedButton.icon(
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Refresh'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: segments.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (_, index) => _TimelineCard(segment: segments[index]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimelineCard extends StatelessWidget {
|
||||||
|
final _TimelineSegment segment;
|
||||||
|
|
||||||
|
const _TimelineCard({required this.segment});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEFF6FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(segment.icon, color: const Color(0xFF1D4ED8)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(segment.title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(segment.subtitle,
|
||||||
|
style: const TextStyle(color: Color(0xFF64748B))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimelineSegment {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _TimelineSegment({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_TimelineSegment> _segments(List<Map<String, dynamic>> history) {
|
||||||
|
final items = [...history]..sort((a, b) => _time(a).compareTo(_time(b)));
|
||||||
|
if (items.length < 2) return const [];
|
||||||
|
final chunks = <_TimelineSegment>[];
|
||||||
|
for (var i = 0; i < items.length - 1; i += 6) {
|
||||||
|
final start = items[i];
|
||||||
|
final end = items[math.min(i + 5, items.length - 1)];
|
||||||
|
final distance = _distanceBetween(start, end);
|
||||||
|
final speed = _avgSpeed(items.sublist(i, math.min(i + 6, items.length)));
|
||||||
|
final mode = speed > 2.8
|
||||||
|
? 'Naik kendaraan'
|
||||||
|
: speed > 1.1
|
||||||
|
? 'Berjalan cepat'
|
||||||
|
: 'Berjalan';
|
||||||
|
chunks.add(
|
||||||
|
_TimelineSegment(
|
||||||
|
title: '${_clock(start)} - ${_clock(end)} $mode',
|
||||||
|
subtitle:
|
||||||
|
'${distance.toStringAsFixed(0)} m, avg ${speed.toStringAsFixed(1)} m/s',
|
||||||
|
icon: speed > 2.8 ? Icons.directions_bike : Icons.directions_walk,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return chunks.reversed.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LatLng> _pointsFrom(List<Map<String, dynamic>> history) {
|
||||||
|
return history.map(_pointFrom).whereType<LatLng>().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng? _pointFrom(Map<String, dynamic>? item) {
|
||||||
|
if (item == null) return null;
|
||||||
|
final lat = (item['lat'] as num?)?.toDouble();
|
||||||
|
final lng = (item['lng'] as num?)?.toDouble();
|
||||||
|
if (lat == null || lng == null) return null;
|
||||||
|
return LatLng(lat, lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _time(Map<String, dynamic> item) {
|
||||||
|
return DateTime.tryParse(item['createdAt']?.toString() ?? '') ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _clock(Map<String, dynamic> item) {
|
||||||
|
final time = _time(item).toLocal();
|
||||||
|
return DateFormat('HH:mm').format(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _distanceBetween(Map<String, dynamic> a, Map<String, dynamic> b) {
|
||||||
|
final start = _pointFrom(a);
|
||||||
|
final end = _pointFrom(b);
|
||||||
|
if (start == null || end == null) return 0;
|
||||||
|
return const Distance().as(LengthUnit.Meter, start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _avgSpeed(List<Map<String, dynamic>> items) {
|
||||||
|
final speeds = items
|
||||||
|
.map((e) => (e['speed'] as num?)?.toDouble())
|
||||||
|
.whereType<double>()
|
||||||
|
.where((speed) => speed >= 0)
|
||||||
|
.toList();
|
||||||
|
if (speeds.isEmpty) return 0;
|
||||||
|
return speeds.reduce((a, b) => a + b) / speeds.length;
|
||||||
|
}
|
||||||
@ -1,18 +1,15 @@
|
|||||||
export '../home/presentation/guardian_dashboard_screen.dart'
|
export '../home/presentation/guardian_dashboard_screen.dart'
|
||||||
show GuardianDashboardScreen;
|
show GuardianDashboardScreen;
|
||||||
|
|
||||||
export 'guardian_activity_log_screen.dart'
|
export 'guardian_activity_log_screen.dart' show GuardianActivityLogScreen;
|
||||||
show
|
|
||||||
GuardianActivityLogScreen;
|
|
||||||
|
|
||||||
export 'guardian_ai_config_screen.dart'
|
export 'guardian_ai_config_screen.dart' show GuardianAiConfigScreen;
|
||||||
show
|
|
||||||
GuardianAiConfigScreen;
|
|
||||||
|
|
||||||
export '../screens.dart'
|
export 'guardian_map_screen.dart' show GuardianMapScreen;
|
||||||
show
|
|
||||||
GuardianMapScreen,
|
export 'guardian_send_notification_screen.dart' show GuardianSendNotifScreen;
|
||||||
GuardianSendNotifScreen,
|
|
||||||
GuardianVoiceCmdScreen,
|
export 'guardian_settings_screen.dart' show GuardianSettingsScreen;
|
||||||
GuardianShortcutScreen,
|
|
||||||
GuardianGeofenceScreen;
|
export 'guardian_tools_screen.dart'
|
||||||
|
show GuardianVoiceCmdScreen, GuardianShortcutScreen, GuardianGeofenceScreen;
|
||||||
|
|||||||
@ -0,0 +1,108 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
|
import '../../core/network/api_client.dart';
|
||||||
|
import '../../shared/widgets/feature_page.dart';
|
||||||
|
|
||||||
|
class GuardianSendNotifScreen extends StatefulWidget {
|
||||||
|
const GuardianSendNotifScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GuardianSendNotifScreen> createState() =>
|
||||||
|
_GuardianSendNotifScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
||||||
|
final _message = TextEditingController();
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_message.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _send() async {
|
||||||
|
final message = _message.text.trim();
|
||||||
|
if (message.isEmpty) {
|
||||||
|
_snack('Tulis pesan dulu.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _loading = true);
|
||||||
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
|
await sl<ApiClient>().dio.post('/guardian/notifications/send', data: {
|
||||||
|
'notifType': 'TEXT',
|
||||||
|
'content': message,
|
||||||
|
}).timeout(const Duration(seconds: 8));
|
||||||
|
_message.clear();
|
||||||
|
_snack('Notifikasi terkirim ke User.');
|
||||||
|
},
|
||||||
|
onError: _snack,
|
||||||
|
fallback: 'Gagal mengirim notifikasi. Coba lagi.',
|
||||||
|
);
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _snack(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FeaturePage(
|
||||||
|
title: 'Send Notification',
|
||||||
|
subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
|
||||||
|
child: ListView(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF1E293B).withValues(alpha: 0.06),
|
||||||
|
blurRadius: 22,
|
||||||
|
offset: const Offset(0, 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _message,
|
||||||
|
minLines: 5,
|
||||||
|
maxLines: 8,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Message',
|
||||||
|
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
|
||||||
|
prefixIcon: Icon(Icons.message_outlined),
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _loading ? null : _send,
|
||||||
|
icon: _loading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.send),
|
||||||
|
label: Text(_loading ? 'Sending...' : 'Send Message'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../app/app_cubit.dart';
|
||||||
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
|
import '../../core/network/api_client.dart';
|
||||||
|
import '../../core/storage/secure_storage.dart';
|
||||||
|
import '../../shared/widgets/feature_page.dart';
|
||||||
|
|
||||||
|
class GuardianSettingsScreen extends StatelessWidget {
|
||||||
|
const GuardianSettingsScreen({super.key});
|
||||||
|
|
||||||
|
Future<void> _logout(BuildContext context) async {
|
||||||
|
final appCubit = context.read<AppCubit>();
|
||||||
|
await sl<SecureStorage>().clearAll();
|
||||||
|
appCubit.clearSession();
|
||||||
|
unawaited(_notifyBackendLogout());
|
||||||
|
if (context.mounted) context.go('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _notifyBackendLogout() async {
|
||||||
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
|
await sl<ApiClient>()
|
||||||
|
.dio
|
||||||
|
.post('/auth/logout')
|
||||||
|
.timeout(const Duration(seconds: 3));
|
||||||
|
},
|
||||||
|
onError: (_) {},
|
||||||
|
fallback: 'Logout server belum bisa dikonfirmasi.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _changeServer(BuildContext context) async {
|
||||||
|
await AppConstants.clearServerUrl();
|
||||||
|
await sl<SecureStorage>().clearAll();
|
||||||
|
if (context.mounted) context.go('/server-connect');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FeaturePage(
|
||||||
|
title: 'Guardian Settings',
|
||||||
|
subtitle: 'Account, pairing, AI tools, and server',
|
||||||
|
child: ListView(
|
||||||
|
children: [
|
||||||
|
_SettingsTile(
|
||||||
|
icon: Icons.link,
|
||||||
|
title: 'Pair User',
|
||||||
|
subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
|
||||||
|
onTap: () => context.go('/guardian/pairing'),
|
||||||
|
),
|
||||||
|
_SettingsTile(
|
||||||
|
icon: Icons.speed,
|
||||||
|
title: 'AI Benchmark',
|
||||||
|
subtitle: 'Catat capture, inference, notification, dan TTS.',
|
||||||
|
onTap: () => context.go('/guardian/benchmark'),
|
||||||
|
),
|
||||||
|
_SettingsTile(
|
||||||
|
icon: Icons.tune,
|
||||||
|
title: 'AI Config',
|
||||||
|
subtitle: 'Atur threshold deteksi dan label yang aktif.',
|
||||||
|
onTap: () => context.go('/guardian/ai-config'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _changeServer(context),
|
||||||
|
icon: const Icon(Icons.dns_outlined),
|
||||||
|
label: const Text('Change server'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFDC2626),
|
||||||
|
),
|
||||||
|
onPressed: () => _logout(context),
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
label: const Text('Logout'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsTile extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _SettingsTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(icon, color: const Color(0xFF1D4ED8)),
|
||||||
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||||
|
subtitle: Text(subtitle),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,199 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
|
import '../../core/network/api_client.dart';
|
||||||
|
import '../../shared/widgets/feature_page.dart';
|
||||||
|
|
||||||
|
class GuardianVoiceCmdScreen extends StatelessWidget {
|
||||||
|
const GuardianVoiceCmdScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const _GuardianEndpointScreen(
|
||||||
|
title: 'Voice Commands',
|
||||||
|
subtitle: 'Daftar voice command yang aktif untuk User',
|
||||||
|
endpoint: '/guardian/voice-commands',
|
||||||
|
icon: Icons.record_voice_over_outlined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class GuardianShortcutScreen extends StatelessWidget {
|
||||||
|
const GuardianShortcutScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const _GuardianEndpointScreen(
|
||||||
|
title: 'Hardware Shortcuts',
|
||||||
|
subtitle: 'Shortcut tombol untuk aksi cepat WalkGuide',
|
||||||
|
endpoint: '/guardian/shortcuts',
|
||||||
|
icon: Icons.keyboard_command_key_outlined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class GuardianGeofenceScreen extends StatelessWidget {
|
||||||
|
const GuardianGeofenceScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const _GuardianEndpointScreen(
|
||||||
|
title: 'Geofence',
|
||||||
|
subtitle: 'Area aman dan peringatan lokasi User',
|
||||||
|
endpoint: '/guardian/geofence',
|
||||||
|
icon: Icons.fence_outlined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GuardianEndpointScreen extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final String endpoint;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _GuardianEndpointScreen({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.endpoint,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_GuardianEndpointScreen> createState() =>
|
||||||
|
_GuardianEndpointScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
List<Map<String, dynamic>> _items = const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
|
final res = await sl<ApiClient>().dio.get(widget.endpoint,
|
||||||
|
queryParameters: {'size': 50}).timeout(const Duration(seconds: 8));
|
||||||
|
_items = _extractList(res.data);
|
||||||
|
},
|
||||||
|
onError: (message) => _error = message,
|
||||||
|
fallback: 'Data belum bisa dimuat. Coba refresh lagi.',
|
||||||
|
);
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _extractList(dynamic body) {
|
||||||
|
final data = body is Map ? body['data'] : body;
|
||||||
|
final raw = data is Map ? data['content'] : data;
|
||||||
|
if (raw is! List) return const [];
|
||||||
|
return raw
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((item) => Map<String, dynamic>.from(item))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FeaturePage(
|
||||||
|
title: widget.title,
|
||||||
|
subtitle: widget.subtitle,
|
||||||
|
trailing: IconButton(
|
||||||
|
onPressed: _loading ? null : _load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
),
|
||||||
|
child: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null
|
||||||
|
? FeatureErrorPanel(message: _error!, onRetry: _load)
|
||||||
|
: _items.isEmpty
|
||||||
|
? FeatureEmptyPanel(
|
||||||
|
icon: widget.icon,
|
||||||
|
title: 'Belum ada data',
|
||||||
|
message:
|
||||||
|
'Data akan muncul setelah Guardian membuat konfigurasi atau User mulai memakai fitur ini.',
|
||||||
|
action: OutlinedButton.icon(
|
||||||
|
onPressed: _load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Refresh'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: _items.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (_, index) => _EndpointCard(
|
||||||
|
icon: widget.icon,
|
||||||
|
item: _items[index],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EndpointCard extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final Map<String, dynamic> item;
|
||||||
|
|
||||||
|
const _EndpointCard({required this.icon, required this.item});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final title = _firstText(item, ['name', 'command', 'label', 'type']) ??
|
||||||
|
'Item #${item['id'] ?? '-'}';
|
||||||
|
final subtitle = _firstText(
|
||||||
|
item,
|
||||||
|
['description', 'action', 'shortcut', 'status', 'createdAt'],
|
||||||
|
) ??
|
||||||
|
'Data aktif';
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEFF6FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: const Color(0xFF1D4ED8)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(subtitle,
|
||||||
|
style: const TextStyle(color: Color(0xFF64748B))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _firstText(Map<String, dynamic> item, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = item[key]?.toString().trim();
|
||||||
|
if (value != null && value.isNotEmpty && value != 'null') return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import 'dart:async';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
@ -38,12 +39,14 @@ class _Step {
|
|||||||
required this.point});
|
required this.point});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── BLoC-lite state (plain ChangeNotifier to avoid heavy BLoC boilerplate
|
// Navigation state is kept in a lightweight Cubit so the app uses one
|
||||||
// while staying consistent with the rest of screens.dart approach) ─────
|
// state-management family consistently.
|
||||||
|
|
||||||
enum _NavPhase { idle, locating, routing, navigating, error }
|
enum _NavPhase { idle, locating, routing, navigating, error }
|
||||||
|
|
||||||
class _NavState extends ChangeNotifier {
|
class _NavState extends Cubit<int> {
|
||||||
|
_NavState() : super(0);
|
||||||
|
|
||||||
_NavPhase phase = _NavPhase.idle;
|
_NavPhase phase = _NavPhase.idle;
|
||||||
String statusText = 'Ketuk tombol lokasi atau cari tujuan.';
|
String statusText = 'Ketuk tombol lokasi atau cari tujuan.';
|
||||||
LatLng? currentPosition;
|
LatLng? currentPosition;
|
||||||
@ -59,9 +62,11 @@ class _NavState extends ChangeNotifier {
|
|||||||
void _set(_NavPhase p, String status) {
|
void _set(_NavPhase p, String status) {
|
||||||
phase = p;
|
phase = p;
|
||||||
statusText = status;
|
statusText = status;
|
||||||
notifyListeners();
|
_notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _notify() => emit(state + 1);
|
||||||
|
|
||||||
// ── locate ──────────────────────────────────────────────────────────────
|
// ── locate ──────────────────────────────────────────────────────────────
|
||||||
Future<bool> locate() async {
|
Future<bool> locate() async {
|
||||||
_set(_NavPhase.locating, 'Mencari lokasi GPS…');
|
_set(_NavPhase.locating, 'Mencari lokasi GPS…');
|
||||||
@ -85,7 +90,8 @@ class _NavState extends ChangeNotifier {
|
|||||||
_set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.');
|
_set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.');
|
||||||
return false;
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_set(_NavPhase.error, 'GPS error: $e');
|
_set(_NavPhase.error,
|
||||||
|
'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -211,10 +217,11 @@ class _NavState extends ChangeNotifier {
|
|||||||
|
|
||||||
_set(_NavPhase.navigating,
|
_set(_NavPhase.navigating,
|
||||||
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
|
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
|
||||||
notifyListeners();
|
_notify();
|
||||||
_startTracking();
|
_startTracking();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_set(_NavPhase.error, 'Gagal mendapat rute: $e');
|
_set(_NavPhase.error,
|
||||||
|
'Gagal mendapat rute. Periksa koneksi lalu coba lagi.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,7 +290,7 @@ class _NavState extends ChangeNotifier {
|
|||||||
currentPosition = LatLng(pos.latitude, pos.longitude);
|
currentPosition = LatLng(pos.latitude, pos.longitude);
|
||||||
_reportToBackend(pos);
|
_reportToBackend(pos);
|
||||||
_updateStep();
|
_updateStep();
|
||||||
notifyListeners();
|
_notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -302,7 +309,7 @@ class _NavState extends ChangeNotifier {
|
|||||||
final next = steps[currentStepIndex];
|
final next = steps[currentStepIndex];
|
||||||
statusText = next.instruction;
|
statusText = next.instruction;
|
||||||
sl<TtsService>().speak(next.instruction);
|
sl<TtsService>().speak(next.instruction);
|
||||||
notifyListeners();
|
_notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -318,9 +325,9 @@ class _NavState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
Future<void> close() {
|
||||||
_posStream?.cancel();
|
_posStream?.cancel();
|
||||||
super.dispose();
|
return super.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -339,6 +346,7 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
|
|||||||
final _searchCtrl = TextEditingController();
|
final _searchCtrl = TextEditingController();
|
||||||
final _searchFocus = FocusNode();
|
final _searchFocus = FocusNode();
|
||||||
|
|
||||||
|
StreamSubscription<int>? _navSubscription;
|
||||||
List<_Place> _suggestions = const [];
|
List<_Place> _suggestions = const [];
|
||||||
bool _searchLoading = false;
|
bool _searchLoading = false;
|
||||||
bool _showSuggestions = false;
|
bool _showSuggestions = false;
|
||||||
@ -348,7 +356,7 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_navState.addListener(_onStateChange);
|
_navSubscription = _navState.stream.listen((_) => _onStateChange());
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -433,8 +441,8 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_navState.removeListener(_onStateChange);
|
_navSubscription?.cancel();
|
||||||
_navState.dispose();
|
_navState.close();
|
||||||
_searchCtrl.dispose();
|
_searchCtrl.dispose();
|
||||||
_searchFocus.dispose();
|
_searchFocus.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
@ -36,7 +37,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
_loading = true;
|
_loading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/user/notifications')
|
.get('/user/notifications')
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
@ -44,17 +46,12 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_items = list.map((e) => _NotifItem.fromJson(e)).toList();
|
_items = list.map((e) => _NotifItem.fromJson(e)).toList();
|
||||||
});
|
});
|
||||||
} on DioException catch (e) {
|
},
|
||||||
setState(() {
|
onError: (message) => setState(() => _error = message),
|
||||||
_error = e.response?.data?['message']?.toString() ??
|
fallback: 'Gagal memuat notifikasi. Coba refresh lagi.',
|
||||||
'Gagal memuat notifikasi.';
|
);
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() => _error = 'Timeout / error: $e');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
|
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
|
||||||
final data = responseBody is Map ? responseBody['data'] : null;
|
final data = responseBody is Map ? responseBody['data'] : null;
|
||||||
@ -67,7 +64,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _markRead(int id) async {
|
Future<void> _markRead(int id) async {
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
await _api
|
await _api
|
||||||
.put('/user/notifications/$id/read')
|
.put('/user/notifications/$id/read')
|
||||||
.timeout(const Duration(seconds: 6));
|
.timeout(const Duration(seconds: 6));
|
||||||
@ -75,12 +73,16 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
final idx = _items.indexWhere((n) => n.id == id);
|
final idx = _items.indexWhere((n) => n.id == id);
|
||||||
if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true);
|
if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true);
|
||||||
});
|
});
|
||||||
} catch (_) {}
|
},
|
||||||
|
onError: (_) {},
|
||||||
|
fallback: 'Gagal menandai notifikasi.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _markAllRead() async {
|
Future<void> _markAllRead() async {
|
||||||
setState(() => _markingAll = true);
|
setState(() => _markingAll = true);
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
await _api
|
await _api
|
||||||
.put('/user/notifications/mark-all-read')
|
.put('/user/notifications/mark-all-read')
|
||||||
.timeout(const Duration(seconds: 8));
|
.timeout(const Duration(seconds: 8));
|
||||||
@ -88,15 +90,12 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
_items = _items.map((n) => n.copyWith(isRead: true)).toList();
|
_items = _items.map((n) => n.copyWith(isRead: true)).toList();
|
||||||
});
|
});
|
||||||
_snack('Semua notifikasi ditandai sudah dibaca.');
|
_snack('Semua notifikasi ditandai sudah dibaca.');
|
||||||
} on DioException catch (e) {
|
},
|
||||||
_snack(e.response?.data?['message']?.toString() ??
|
onError: _snack,
|
||||||
'Gagal menandai semua dibaca.');
|
fallback: 'Gagal menandai semua dibaca.',
|
||||||
} catch (_) {
|
);
|
||||||
_snack('Timeout. Coba lagi.');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _markingAll = false);
|
if (mounted) setState(() => _markingAll = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _readAloud(_NotifItem notif) async {
|
Future<void> _readAloud(_NotifItem notif) async {
|
||||||
final tts = sl<TtsService>();
|
final tts = sl<TtsService>();
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/storage/secure_storage.dart';
|
import '../../core/storage/secure_storage.dart';
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ import '../../core/storage/secure_storage.dart';
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Ditampilkan ke akun ROLE_USER.
|
// Ditampilkan ke akun ROLE_USER.
|
||||||
// - Tampilkan uniqueUserId mereka (besar, bisa di-copy/share).
|
// - Tampilkan pairing code sementara yang bisa di-copy/share.
|
||||||
// - Jika ada pending invite → tampilkan nama Guardian + tombol Accept / Reject.
|
// - Jika ada pending invite → tampilkan nama Guardian + tombol Accept / Reject.
|
||||||
// - Jika sudah paired → tampilkan info Guardian + tombol Unpair.
|
// - Jika sudah paired → tampilkan info Guardian + tombol Unpair.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -28,6 +28,10 @@ class UserPairingScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _UserPairingScreenState extends State<UserPairingScreen> {
|
class _UserPairingScreenState extends State<UserPairingScreen> {
|
||||||
String? _uniqueId;
|
String? _uniqueId;
|
||||||
|
String? _pairingCode;
|
||||||
|
DateTime? _pairingCodeExpiresAt;
|
||||||
|
int? _pairingCodeSeconds;
|
||||||
|
bool _regenerating = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -38,36 +42,87 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
|
|||||||
Future<void> _loadUniqueId() async {
|
Future<void> _loadUniqueId() async {
|
||||||
var value = await sl<SecureStorage>().getUniqueUserId();
|
var value = await sl<SecureStorage>().getUniqueUserId();
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final res = await sl<ApiClient>()
|
final res = await sl<ApiClient>()
|
||||||
.dio
|
.dio
|
||||||
.get('/user/profile')
|
.get('/user/profile')
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
final data = res.data['data'];
|
final data = res.data['data'];
|
||||||
if (data is Map) value = data['uniqueUserId']?.toString();
|
if (data is Map) value = data['uniqueUserId']?.toString();
|
||||||
} catch (_) {}
|
},
|
||||||
|
onError: (_) {},
|
||||||
|
fallback: 'Profil belum bisa dimuat.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (mounted) setState(() => _uniqueId = value);
|
if (mounted) setState(() => _uniqueId = value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _regeneratePairingCode() async {
|
||||||
|
setState(() => _regenerating = true);
|
||||||
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
|
final res = await sl<ApiClient>()
|
||||||
|
.dio
|
||||||
|
.post('/shared/pairing/code/regenerate')
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
_applyPairingCode(res.data['data']);
|
||||||
|
_snack(context, 'Pairing code baru sudah dibuat.');
|
||||||
|
},
|
||||||
|
onError: (message) => _snack(context, message),
|
||||||
|
fallback: 'Gagal membuat pairing code baru.',
|
||||||
|
);
|
||||||
|
if (mounted) setState(() => _regenerating = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyPairingCode(dynamic raw) {
|
||||||
|
if (raw is! Map) return;
|
||||||
|
final expires = DateTime.tryParse(raw['expiresAt']?.toString() ?? '');
|
||||||
|
setState(() {
|
||||||
|
_pairingCode = raw['pairingCode']?.toString();
|
||||||
|
_pairingCodeExpiresAt = expires;
|
||||||
|
_pairingCodeSeconds = int.tryParse(raw['expiresInSeconds'].toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _Page(
|
return _Page(
|
||||||
title: 'Pairing',
|
title: 'Pairing',
|
||||||
subtitle: 'Bagikan Unique ID ini ke Guardian untuk terhubung.',
|
subtitle: 'Bagikan pairing code sementara ini ke Guardian.',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (_uniqueId == null || _uniqueId!.isEmpty)
|
if (_pairingCode == null || _pairingCode!.isEmpty)
|
||||||
_InfoCard(
|
_InfoCard(
|
||||||
title: 'Your Unique ID',
|
title: 'Pairing Code',
|
||||||
value: 'Login sebagai User untuk melihat ID',
|
value: 'Tap Generate',
|
||||||
icon: Icons.qr_code_2)
|
icon: Icons.qr_code_2,
|
||||||
|
helper:
|
||||||
|
'Kode dibuat saat dibutuhkan, berlaku sementara, dan bisa dibuat ulang kapan saja.')
|
||||||
else
|
else
|
||||||
_InfoCard(
|
_InfoCard(
|
||||||
title: 'Your Unique ID',
|
title: 'Pairing Code',
|
||||||
value: _uniqueId!,
|
value: _pairingCode!,
|
||||||
icon: Icons.qr_code_2),
|
icon: Icons.qr_code_2,
|
||||||
|
helper:
|
||||||
|
'Valid ${_formatRemaining(_pairingCodeSeconds, _pairingCodeExpiresAt)}. Kode ini akan berubah dan kadaluarsa otomatis.'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _regenerating ? null : _regeneratePairingCode,
|
||||||
|
icon: _regenerating
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Icon(Icons.autorenew),
|
||||||
|
label: Text(_regenerating ? 'Generating...' : 'Generate New Code'),
|
||||||
|
),
|
||||||
|
if (_uniqueId != null && _uniqueId!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Account ID: $_uniqueId',
|
||||||
|
style: const TextStyle(color: Color(0xFF64748B), fontSize: 12)),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_PairingStatusCard(allowUserResponse: true),
|
_PairingStatusCard(allowUserResponse: true),
|
||||||
],
|
],
|
||||||
@ -81,7 +136,7 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Ditampilkan ke akun ROLE_GUARDIAN.
|
// Ditampilkan ke akun ROLE_GUARDIAN.
|
||||||
// - Input field 12-char User ID.
|
// - Input field 8-char temporary pairing code.
|
||||||
// - Tombol "Send Invite".
|
// - Tombol "Send Invite".
|
||||||
// - Status card: jika sudah paired → info User + tombol Unpair.
|
// - Status card: jika sudah paired → info User + tombol Unpair.
|
||||||
// Jika pending → waiting state.
|
// Jika pending → waiting state.
|
||||||
@ -100,51 +155,46 @@ class _GuardianPairingScreenState extends State<GuardianPairingScreen> {
|
|||||||
int _statusReload = 0;
|
int _statusReload = 0;
|
||||||
|
|
||||||
Future<void> _invite() async {
|
Future<void> _invite() async {
|
||||||
final uniqueId = _id.text.trim().toUpperCase();
|
final pairingCode = _id.text.trim().toUpperCase();
|
||||||
if (uniqueId.isEmpty || uniqueId.length != 12) {
|
if (pairingCode.isEmpty || pairingCode.length != 8) {
|
||||||
_snack(context, 'Unique ID harus 12 karakter dari akun User.');
|
_snack(context, 'Pairing code harus 8 karakter dari akun User.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final res = await sl<ApiClient>().dio.post('/shared/pairing/invite',
|
final res = await sl<ApiClient>().dio.post('/shared/pairing/invite',
|
||||||
data: {'uniqueUserId': uniqueId}).timeout(const Duration(seconds: 8));
|
data: {
|
||||||
|
'pairingCode': pairingCode
|
||||||
|
}).timeout(const Duration(seconds: 8));
|
||||||
_snack(
|
_snack(
|
||||||
context,
|
context,
|
||||||
res.data['message']?.toString() ??
|
res.data['message']?.toString() ??
|
||||||
'Invite terkirim. Minta User buka menu Pairing lalu Accept.');
|
'Invite terkirim. Minta User buka menu Pairing lalu Accept.');
|
||||||
setState(() => _statusReload++);
|
setState(() => _statusReload++);
|
||||||
} on DioException catch (e) {
|
},
|
||||||
_snack(
|
onError: (message) => _snack(context, message),
|
||||||
context,
|
|
||||||
_friendlyDioMessage(e,
|
|
||||||
fallback:
|
fallback:
|
||||||
'Invite gagal. Pastikan kamu login sebagai Guardian dan ID User benar.'));
|
'Invite gagal. Pastikan kamu login sebagai Guardian dan pairing code benar.',
|
||||||
} on TimeoutException {
|
);
|
||||||
_snack(context,
|
|
||||||
'Server terlalu lama merespons invite. Coba Refresh status, jangan klik berkali-kali.');
|
|
||||||
} catch (e) {
|
|
||||||
_snack(context, 'Invite gagal: $e');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _Page(
|
return _Page(
|
||||||
title: 'Pair User',
|
title: 'Pair User',
|
||||||
subtitle: 'Masukkan 12 karakter Unique ID milik User.',
|
subtitle: 'Masukkan 8 karakter pairing code aktif dari User.',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: _id,
|
controller: _id,
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
maxLength: 12,
|
maxLength: 8,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Unique User ID',
|
labelText: 'Pairing Code',
|
||||||
hintText: 'Contoh: AB1C2D3E4F5G',
|
hintText: 'Contoh: A7K9Q2M4',
|
||||||
prefixIcon: Icon(Icons.link),
|
prefixIcon: Icon(Icons.link),
|
||||||
)),
|
)),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
@ -192,7 +242,8 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
|||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final token = await sl<SecureStorage>().getAccessToken();
|
final token = await sl<SecureStorage>().getAccessToken();
|
||||||
if (token == null || token.isEmpty) {
|
if (token == null || token.isEmpty) {
|
||||||
_active = false;
|
_active = false;
|
||||||
@ -216,27 +267,18 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
|||||||
? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.'
|
? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.'
|
||||||
: 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.';
|
: 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.';
|
||||||
} else {
|
} else {
|
||||||
_status = 'Belum pairing. Bagikan Unique ID kamu ke Guardian.';
|
_status = 'Belum pairing. Bagikan pairing code aktif ke Guardian.';
|
||||||
}
|
}
|
||||||
} on DioException catch (e) {
|
},
|
||||||
|
onError: (message) {
|
||||||
_active = false;
|
_active = false;
|
||||||
_data = null;
|
_data = null;
|
||||||
_status = _friendlyDioMessage(e,
|
_status = message;
|
||||||
fallback:
|
},
|
||||||
'Belum bisa mengecek server, tapi Unique ID tetap bisa dibagikan.');
|
fallback: 'Status pairing belum bisa dicek. Coba refresh lagi.',
|
||||||
} on TimeoutException {
|
);
|
||||||
_active = false;
|
|
||||||
_data = null;
|
|
||||||
_status =
|
|
||||||
'Server terlalu lama merespons status pairing. Cek backend masih running dan URL server benar.';
|
|
||||||
} catch (e) {
|
|
||||||
_active = false;
|
|
||||||
_data = null;
|
|
||||||
_status = 'Status pairing belum bisa dicek: $e';
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _respond(bool accept) async {
|
Future<void> _respond(bool accept) async {
|
||||||
final pairingId = _data?['pairingId'];
|
final pairingId = _data?['pairingId'];
|
||||||
@ -245,7 +287,8 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _responding = true);
|
setState(() => _responding = true);
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final res =
|
final res =
|
||||||
await sl<ApiClient>().dio.post('/shared/pairing/respond', data: {
|
await sl<ApiClient>().dio.post('/shared/pairing/respond', data: {
|
||||||
'pairingId': pairingId,
|
'pairingId': pairingId,
|
||||||
@ -256,15 +299,12 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
|||||||
res.data['message']?.toString() ??
|
res.data['message']?.toString() ??
|
||||||
(accept ? 'Pairing diterima.' : 'Pairing ditolak.'));
|
(accept ? 'Pairing diterima.' : 'Pairing ditolak.'));
|
||||||
await _load();
|
await _load();
|
||||||
} on DioException catch (e) {
|
},
|
||||||
_snack(context,
|
onError: (message) => _snack(context, message),
|
||||||
_friendlyDioMessage(e, fallback: 'Gagal merespons pairing.'));
|
fallback: 'Gagal merespons pairing.',
|
||||||
} on TimeoutException {
|
);
|
||||||
_snack(context, 'Server terlalu lama merespons pairing.');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _responding = false);
|
if (mounted) setState(() => _responding = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _unpair() async {
|
Future<void> _unpair() async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
@ -287,20 +327,20 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
|||||||
);
|
);
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
setState(() => _responding = true);
|
setState(() => _responding = true);
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
await sl<ApiClient>()
|
await sl<ApiClient>()
|
||||||
.dio
|
.dio
|
||||||
.delete('/shared/pairing/unpair')
|
.delete('/shared/pairing/unpair')
|
||||||
.timeout(const Duration(seconds: 8));
|
.timeout(const Duration(seconds: 8));
|
||||||
_snack(context, 'Pairing telah diputus.');
|
_snack(context, 'Pairing telah diputus.');
|
||||||
await _load();
|
await _load();
|
||||||
} on DioException catch (e) {
|
},
|
||||||
_snack(
|
onError: (message) => _snack(context, message),
|
||||||
context, _friendlyDioMessage(e, fallback: 'Gagal memutus pairing.'));
|
fallback: 'Gagal memutus pairing.',
|
||||||
} finally {
|
);
|
||||||
if (mounted) setState(() => _responding = false);
|
if (mounted) setState(() => _responding = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -424,8 +464,12 @@ class _InfoCard extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final String value;
|
final String value;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
final String? helper;
|
||||||
const _InfoCard(
|
const _InfoCard(
|
||||||
{required this.title, required this.value, required this.icon});
|
{required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
this.helper});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -445,7 +489,13 @@ class _InfoCard extends StatelessWidget {
|
|||||||
Text(title),
|
Text(title),
|
||||||
SelectableText(value,
|
SelectableText(value,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 22, fontWeight: FontWeight.w800))
|
fontSize: 22, fontWeight: FontWeight.w800)),
|
||||||
|
if (helper != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(helper!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF64748B), fontSize: 12)),
|
||||||
|
],
|
||||||
])),
|
])),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -464,21 +514,11 @@ void _snack(BuildContext context, String message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _friendlyDioMessage(DioException e, {required String fallback}) {
|
String _formatRemaining(int? seconds, DateTime? expiresAt) {
|
||||||
final data = e.response?.data;
|
final value = seconds ?? expiresAt?.difference(DateTime.now()).inSeconds;
|
||||||
if (data is Map && data['message'] != null) return data['message'].toString();
|
if (value == null || value <= 0) return 'sudah kadaluarsa';
|
||||||
if (e.response?.statusCode == 401) {
|
final minutes = value ~/ 60;
|
||||||
return 'Sesi login habis. Logout lalu login ulang.';
|
final secs = value % 60;
|
||||||
}
|
if (minutes <= 0) return '$secs detik';
|
||||||
if (e.response?.statusCode == 403) {
|
return '$minutes menit ${secs.toString().padLeft(2, '0')} detik';
|
||||||
return 'Role akun tidak cocok untuk fitur ini. Pastikan User/Guardian benar.';
|
|
||||||
}
|
|
||||||
if (e.type == DioExceptionType.connectionTimeout ||
|
|
||||||
e.type == DioExceptionType.receiveTimeout) {
|
|
||||||
return 'Server terlalu lama merespons. Pastikan backend masih running dan URL server benar.';
|
|
||||||
}
|
|
||||||
if (e.type == DioExceptionType.connectionError) {
|
|
||||||
return 'Tidak bisa ke server. Di Chrome pakai http://localhost:8080. Di HP pakai IP laptop/server, bukan localhost.';
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -37,7 +38,8 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
|||||||
_ok = false;
|
_ok = false;
|
||||||
_message = null;
|
_message = null;
|
||||||
});
|
});
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final clean = AppConstants.normalizeServerUrl(_url.text);
|
final clean = AppConstants.normalizeServerUrl(_url.text);
|
||||||
final res = await Dio(BaseOptions(
|
final res = await Dio(BaseOptions(
|
||||||
connectTimeout: AppConstants.pingTimeout,
|
connectTimeout: AppConstants.pingTimeout,
|
||||||
@ -47,12 +49,12 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
|||||||
_message = _ok
|
_message = _ok
|
||||||
? 'Server aktif dan siap dipakai.'
|
? 'Server aktif dan siap dipakai.'
|
||||||
: 'Server merespons dengan format tidak valid.';
|
: 'Server merespons dengan format tidak valid.';
|
||||||
} catch (e) {
|
},
|
||||||
_message = 'Tidak bisa terhubung. Periksa URL dan jaringan.';
|
onError: (message) => _message = message,
|
||||||
} finally {
|
fallback: 'Tidak bisa terhubung. Periksa URL dan jaringan.',
|
||||||
|
);
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _continue() async {
|
Future<void> _continue() async {
|
||||||
final clean = AppConstants.normalizeServerUrl(_url.text);
|
final clean = AppConstants.normalizeServerUrl(_url.text);
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../app/app_cubit.dart';
|
import '../../app/app_cubit.dart';
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/services/haptic_service.dart';
|
import '../../core/services/haptic_service.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
@ -78,9 +79,11 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
try {
|
await runFriendlyAction(
|
||||||
final res =
|
() async {
|
||||||
await _api.get('/user/settings').timeout(const Duration(seconds: 6));
|
final res = await _api
|
||||||
|
.get('/user/settings')
|
||||||
|
.timeout(const Duration(seconds: 6));
|
||||||
final data = res.data['data'];
|
final data = res.data['data'];
|
||||||
if (data is Map) {
|
if (data is Map) {
|
||||||
_ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID';
|
_ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID';
|
||||||
@ -89,29 +92,33 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
|
|||||||
_warnNoGuardian = data['warnNoGuardian'] as bool? ?? true;
|
_warnNoGuardian = data['warnNoGuardian'] as bool? ?? true;
|
||||||
_hapticEnabled = data['hapticEnabled'] as bool? ?? true;
|
_hapticEnabled = data['hapticEnabled'] as bool? ?? true;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
},
|
||||||
// offline: tetap pakai default / nilai lokal
|
onError: (_) {},
|
||||||
}
|
fallback: 'Settings belum bisa dimuat.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadPairing() async {
|
Future<void> _loadPairing() async {
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/shared/pairing/status')
|
.get('/shared/pairing/status')
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
final data = res.data['data'];
|
final data = res.data['data'];
|
||||||
if (data is Map) {
|
if (data is Map) {
|
||||||
_paired = data['status'] == 'ACTIVE';
|
_paired = data['status'] == 'ACTIVE';
|
||||||
final partner = data['pairedWithName'] ?? data['pairedWithEmail'] ?? '';
|
final partner =
|
||||||
|
data['pairedWithName'] ?? data['pairedWithEmail'] ?? '';
|
||||||
_pairingStatus = _paired
|
_pairingStatus = _paired
|
||||||
? 'Terhubung dengan $partner'
|
? 'Terhubung dengan $partner'
|
||||||
: data['status'] == 'PENDING'
|
: data['status'] == 'PENDING'
|
||||||
? 'Menunggu konfirmasi Guardian'
|
? 'Menunggu konfirmasi Guardian'
|
||||||
: 'Belum paired';
|
: 'Belum paired';
|
||||||
}
|
}
|
||||||
} catch (_) {
|
},
|
||||||
_pairingStatus = 'Tidak bisa cek status';
|
onError: (_) => _pairingStatus = 'Tidak bisa cek status',
|
||||||
}
|
fallback: 'Tidak bisa cek status',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
@ -122,7 +129,8 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
|
|||||||
await sl<HapticService>().success();
|
await sl<HapticService>().success();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
await _api.put('/user/settings', data: {
|
await _api.put('/user/settings', data: {
|
||||||
'ttsLanguage': _ttsLanguage,
|
'ttsLanguage': _ttsLanguage,
|
||||||
'ttsPitch': _ttsPitch,
|
'ttsPitch': _ttsPitch,
|
||||||
@ -132,24 +140,17 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
|
|||||||
}).timeout(const Duration(seconds: 8));
|
}).timeout(const Duration(seconds: 8));
|
||||||
_snack('Settings tersimpan.');
|
_snack('Settings tersimpan.');
|
||||||
sl<TtsService>().speak('Settings disimpan.');
|
sl<TtsService>().speak('Settings disimpan.');
|
||||||
} on DioException catch (e) {
|
},
|
||||||
final msg = e.response?.data['message']?.toString() ??
|
onError: _snack,
|
||||||
'Server tidak merespons, settings lokal sudah diterapkan.';
|
fallback: 'Settings lokal sudah diterapkan, gagal sync ke server.',
|
||||||
_snack(msg);
|
);
|
||||||
} catch (_) {
|
|
||||||
_snack('Settings lokal sudah diterapkan, gagal sync ke server.');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _saving = false);
|
if (mounted) setState(() => _saving = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _logout() async {
|
Future<void> _logout() async {
|
||||||
await sl<SecureStorage>().clearAll();
|
await sl<SecureStorage>().clearAll();
|
||||||
context.read<AppCubit>().clearSession();
|
context.read<AppCubit>().clearSession();
|
||||||
_api
|
_api.post('/auth/logout').timeout(const Duration(seconds: 3)).ignore();
|
||||||
.post('/auth/logout')
|
|
||||||
.timeout(const Duration(seconds: 3))
|
|
||||||
.ignore();
|
|
||||||
if (mounted) context.go('/login');
|
if (mounted) context.go('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/services/haptic_service.dart';
|
import '../../core/services/haptic_service.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
@ -94,15 +95,17 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
// ── API Calls ─────────────────────────────────────────────────────────────
|
// ── API Calls ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<Position?> _getPosition() async {
|
Future<Position?> _getPosition() async {
|
||||||
try {
|
return runFriendly<Position>(
|
||||||
|
() async {
|
||||||
final permission = await Geolocator.requestPermission();
|
final permission = await Geolocator.requestPermission();
|
||||||
if (permission == LocationPermission.denied ||
|
if (permission == LocationPermission.denied ||
|
||||||
permission == LocationPermission.deniedForever) return null;
|
permission == LocationPermission.deniedForever) return null;
|
||||||
return await Geolocator.getCurrentPosition()
|
return await Geolocator.getCurrentPosition()
|
||||||
.timeout(const Duration(seconds: 6));
|
.timeout(const Duration(seconds: 6));
|
||||||
} catch (_) {
|
},
|
||||||
return null;
|
onError: (_) {},
|
||||||
}
|
fallback: 'Lokasi belum bisa dibaca.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadHistory() async {
|
Future<void> _loadHistory() async {
|
||||||
@ -110,7 +113,8 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
_historyLoading = true;
|
_historyLoading = true;
|
||||||
_historyError = null;
|
_historyError = null;
|
||||||
});
|
});
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final res = await _api.get('/user/sos-events',
|
final res = await _api.get('/user/sos-events',
|
||||||
queryParameters: {'size': 10}).timeout(const Duration(seconds: 8));
|
queryParameters: {'size': 10}).timeout(const Duration(seconds: 8));
|
||||||
final data = res.data['data'];
|
final data = res.data['data'];
|
||||||
@ -122,15 +126,12 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
.toList()
|
.toList()
|
||||||
: <_SosEvent>[];
|
: <_SosEvent>[];
|
||||||
setState(() => _events = items);
|
setState(() => _events = items);
|
||||||
} on DioException catch (e) {
|
},
|
||||||
final msg = e.response?.data?['message']?.toString();
|
onError: (message) => setState(() => _historyError = message),
|
||||||
setState(() => _historyError = msg ?? 'Tidak bisa memuat riwayat SOS.');
|
fallback: 'Tidak bisa memuat riwayat SOS.',
|
||||||
} catch (_) {
|
);
|
||||||
setState(() => _historyError = 'Tidak bisa memuat riwayat SOS.');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _historyLoading = false);
|
if (mounted) setState(() => _historyLoading = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _confirmAndSend() async {
|
Future<void> _confirmAndSend() async {
|
||||||
if (_sending) return;
|
if (_sending) return;
|
||||||
@ -178,7 +179,8 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
|
|
||||||
Future<void> _sendSos() async {
|
Future<void> _sendSos() async {
|
||||||
setState(() => _sending = true);
|
setState(() => _sending = true);
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final pos = await _getPosition();
|
final pos = await _getPosition();
|
||||||
await _api.post('/user/sos', data: {
|
await _api.post('/user/sos', data: {
|
||||||
'triggerType': 'BUTTON',
|
'triggerType': 'BUTTON',
|
||||||
@ -189,15 +191,12 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
sl<TtsService>().speak('SOS terkirim ke Guardian.');
|
sl<TtsService>().speak('SOS terkirim ke Guardian.');
|
||||||
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
|
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
|
||||||
await _loadHistory();
|
await _loadHistory();
|
||||||
} on DioException catch (e) {
|
},
|
||||||
final msg = e.response?.data?['message']?.toString() ?? 'Gagal kirim SOS';
|
onError: _snack,
|
||||||
_snack(msg);
|
fallback: 'Gagal kirim SOS. Coba lagi sebentar.',
|
||||||
} catch (e) {
|
);
|
||||||
_snack('Gagal kirim SOS: $e');
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _sending = false);
|
if (mounted) setState(() => _sending = false);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Build ──────────────────────────────────────────────────────────────────
|
// ── Build ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,14 @@
|
|||||||
// ignore_for_file: use_build_context_synchronously, deprecated_member_use
|
// ignore_for_file: use_build_context_synchronously, deprecated_member_use
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/ai/detection_export.dart';
|
import '../../core/ai/detection_export.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/services/haptic_service.dart';
|
import '../../core/services/haptic_service.dart';
|
||||||
import '../../core/services/location_reporter_service.dart';
|
import '../../core/services/location_reporter_service.dart';
|
||||||
@ -36,6 +33,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
bool _processingFrame = false;
|
bool _processingFrame = false;
|
||||||
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -59,7 +57,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_active = next;
|
_active = next;
|
||||||
_status = next ? 'Camera stream active. YOLO ready.' : 'Stopped';
|
_status = next ? _activeStatusText() : 'Stopped';
|
||||||
});
|
});
|
||||||
await sl<ApiClient>()
|
await sl<ApiClient>()
|
||||||
.dio
|
.dio
|
||||||
@ -67,13 +65,30 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
|
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _activeStatusText() {
|
||||||
|
final detector = sl<YoloDetector>();
|
||||||
|
if (kIsWeb) {
|
||||||
|
return 'Camera preview aktif, tapi YOLO TFLite tidak jalan di Chrome/web. Test AI harus lewat APK Android.';
|
||||||
|
}
|
||||||
|
if (!detector.isReady) {
|
||||||
|
return detector.lastError == null
|
||||||
|
? 'YOLO model belum siap. Pastikan assets/models/yolov8n.tflite ada.'
|
||||||
|
: 'YOLO model error: ${detector.lastError}';
|
||||||
|
}
|
||||||
|
return 'Camera stream active. YOLO ready. Waiting for camera frames...';
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _startCamera() async {
|
Future<void> _startCamera() async {
|
||||||
if (_camera != null) return;
|
if (_camera != null) return;
|
||||||
try {
|
try {
|
||||||
final cameras = await availableCameras();
|
final cameras = await availableCameras();
|
||||||
if (cameras.isEmpty) return;
|
if (cameras.isEmpty) return;
|
||||||
|
final backCamera = cameras.firstWhere(
|
||||||
|
(camera) => camera.lensDirection == CameraLensDirection.back,
|
||||||
|
orElse: () => cameras.first,
|
||||||
|
);
|
||||||
final controller = CameraController(
|
final controller = CameraController(
|
||||||
cameras.first,
|
backCamera,
|
||||||
ResolutionPreset.medium,
|
ResolutionPreset.medium,
|
||||||
enableAudio: false,
|
enableAudio: false,
|
||||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||||
@ -86,11 +101,13 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
try {
|
try {
|
||||||
await controller.startImageStream(_onCameraImage);
|
await controller.startImageStream(_onCameraImage);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Preview still works; manual Demo Detect remains available.
|
setState(() => _status = kIsWeb
|
||||||
|
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
||||||
|
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
||||||
}
|
}
|
||||||
setState(() => _camera = controller);
|
setState(() => _camera = controller);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
setState(() => _status = 'Camera unavailable. Demo mode active.');
|
setState(() => _status = 'Camera unavailable.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,15 +135,19 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runYolo(CameraImage image) async {
|
Future<void> _runYolo(CameraImage image) async {
|
||||||
final detection = await sl<YoloDetector>().detect(image);
|
final detector = sl<YoloDetector>();
|
||||||
if (detection == null || !mounted) return;
|
final detection = await detector.detect(image, confidenceThreshold: 0.25);
|
||||||
await _handleDetection(detection);
|
if (detection == null || !mounted) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (now.difference(_lastModelWarningAt) > const Duration(seconds: 3)) {
|
||||||
|
_lastModelWarningAt = now;
|
||||||
|
setState(() => _status = detector.isReady
|
||||||
|
? 'Scanning... ${detector.diagnosticsSummary}'
|
||||||
|
: 'YOLO model belum siap. Pastikan file model tersedia di assets/models.');
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
Future<void> _simulateObstacle() async {
|
}
|
||||||
final detection = await sl<YoloDetector>().detectFallback();
|
await _handleDetection(detection);
|
||||||
if (detection == null) return;
|
|
||||||
await _handleDetection(detection, forceAlert: true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleDetection(
|
Future<void> _handleDetection(
|
||||||
@ -135,7 +156,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
}) async {
|
}) async {
|
||||||
_lastDetection = detection;
|
_lastDetection = detection;
|
||||||
setState(() => _status =
|
setState(() => _status =
|
||||||
'Obstacle: ${detection.label} ${detection.directionName} ${detection.estimatedDistance}');
|
'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}');
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (!forceAlert &&
|
if (!forceAlert &&
|
||||||
@ -187,6 +208,12 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
const Center(
|
const Center(
|
||||||
child: Icon(Icons.videocam_outlined,
|
child: Icon(Icons.videocam_outlined,
|
||||||
color: Colors.white30, size: 96)),
|
color: Colors.white30, size: 96)),
|
||||||
|
if (_lastDetection?.box != null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _DetectionOverlayPainter(_lastDetection!),
|
||||||
|
),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 16,
|
top: 16,
|
||||||
left: 16,
|
left: 16,
|
||||||
@ -199,7 +226,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
left: 16,
|
left: 16,
|
||||||
child: _Pill(
|
child: _Pill(
|
||||||
text:
|
text:
|
||||||
'${_lastDetection!.label} ${_lastDetection!.directionName}',
|
'${ObstacleAnalyzer.spokenLabel(_lastDetection!.label)} ${_lastDetection!.directionName}',
|
||||||
color: Colors.redAccent),
|
color: Colors.redAccent),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@ -223,12 +250,6 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
onPressed: _toggle,
|
onPressed: _toggle,
|
||||||
icon: Icon(_active ? Icons.stop : Icons.play_arrow),
|
icon: Icon(_active ? Icons.stop : Icons.play_arrow),
|
||||||
label: Text(_active ? 'Stop' : 'Start'))),
|
label: Text(_active ? 'Stop' : 'Start'))),
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: _simulateObstacle,
|
|
||||||
icon: const Icon(Icons.radar),
|
|
||||||
label: const Text('Demo Detect'))),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -237,229 +258,74 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
class _DetectionOverlayPainter extends CustomPainter {
|
||||||
// AiBenchmarkScreen
|
final DetectionResult detection;
|
||||||
// ---------------------------------------------------------------------------
|
const _DetectionOverlayPainter(this.detection);
|
||||||
|
|
||||||
class AiBenchmarkScreen extends StatefulWidget {
|
|
||||||
const AiBenchmarkScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AiBenchmarkScreen> createState() => _AiBenchmarkScreenState();
|
void paint(Canvas canvas, Size size) {
|
||||||
}
|
final box = detection.box;
|
||||||
|
if (box == null) return;
|
||||||
|
|
||||||
class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
final sx = size.width / ObstacleAnalyzer.frameWidth;
|
||||||
static const _runsKey = 'ai_benchmark_runs';
|
final sy = size.height / ObstacleAnalyzer.frameHeight;
|
||||||
List<String> _models = const [];
|
final rect = Rect.fromLTRB(
|
||||||
String _selectedModel = AppConstants.yoloModelPath;
|
box.left * sx,
|
||||||
List<Map<String, dynamic>> _runs = const [];
|
box.top * sy,
|
||||||
bool _running = false;
|
box.right * sx,
|
||||||
|
box.bottom * sy,
|
||||||
@override
|
);
|
||||||
void initState() {
|
final color = switch (detection.direction) {
|
||||||
super.initState();
|
ObstacleDirection.center => const Color(0xFFEF4444),
|
||||||
_load();
|
ObstacleDirection.left => const Color(0xFFF59E0B),
|
||||||
}
|
ObstacleDirection.right => const Color(0xFFF59E0B),
|
||||||
|
|
||||||
Future<void> _load() async {
|
|
||||||
final models = await _discoverTfliteModels();
|
|
||||||
final selected = await AppConstants.getSelectedYoloModelPath();
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final rawRuns = prefs.getStringList(_runsKey) ?? const [];
|
|
||||||
setState(() {
|
|
||||||
_models = models.isEmpty ? [selected] : models;
|
|
||||||
_selectedModel = models.contains(selected) ? selected : _models.first;
|
|
||||||
_runs = rawRuns
|
|
||||||
.map((e) => Map<String, dynamic>.from(jsonDecode(e) as Map))
|
|
||||||
.toList()
|
|
||||||
.reversed
|
|
||||||
.toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _setModel(String? value) async {
|
|
||||||
if (value == null) return;
|
|
||||||
await AppConstants.setSelectedYoloModelPath(value);
|
|
||||||
sl<YoloDetector>().dispose();
|
|
||||||
await sl<YoloDetector>().init();
|
|
||||||
setState(() => _selectedModel = value);
|
|
||||||
_snack(context, 'Model aktif: ${value.split('/').last}');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _runBenchmark() async {
|
|
||||||
setState(() => _running = true);
|
|
||||||
final started = DateTime.now();
|
|
||||||
final captureMs = await _measureCapture();
|
|
||||||
|
|
||||||
final inferenceWatch = Stopwatch()..start();
|
|
||||||
String label = 'person';
|
|
||||||
String direction = 'CENTER';
|
|
||||||
String distance = 'Demo';
|
|
||||||
final modelLoaded = sl<YoloDetector>().isReady;
|
|
||||||
final detection = await sl<YoloDetector>().detectSynthetic();
|
|
||||||
if (detection != null) {
|
|
||||||
label = detection.label;
|
|
||||||
direction = detection.directionName;
|
|
||||||
distance = detection.estimatedDistance;
|
|
||||||
}
|
|
||||||
inferenceWatch.stop();
|
|
||||||
|
|
||||||
final notifWatch = Stopwatch()..start();
|
|
||||||
final text = 'Obstacle $label di $direction, jarak $distance';
|
|
||||||
notifWatch.stop();
|
|
||||||
|
|
||||||
final ttsWatch = Stopwatch()..start();
|
|
||||||
try {
|
|
||||||
await sl<TtsService>()
|
|
||||||
.speakImmediate(text)
|
|
||||||
.timeout(const Duration(seconds: 3));
|
|
||||||
} catch (_) {}
|
|
||||||
ttsWatch.stop();
|
|
||||||
|
|
||||||
final run = {
|
|
||||||
'time': started.toIso8601String(),
|
|
||||||
'model': _selectedModel,
|
|
||||||
'modelLoaded': modelLoaded,
|
|
||||||
'captureMs': captureMs,
|
|
||||||
'inferenceMs': inferenceWatch.elapsedMilliseconds,
|
|
||||||
'notificationMs': notifWatch.elapsedMicroseconds / 1000,
|
|
||||||
'ttsMs': ttsWatch.elapsedMilliseconds,
|
|
||||||
'label': label,
|
|
||||||
'direction': direction,
|
|
||||||
};
|
};
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final next = [
|
|
||||||
jsonEncode(run),
|
|
||||||
...((prefs.getStringList(_runsKey) ?? const []).take(24))
|
|
||||||
];
|
|
||||||
await prefs.setStringList(_runsKey, next);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_runs = [run, ..._runs].take(25).toList();
|
|
||||||
_running = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> _measureCapture() async {
|
canvas.drawRRect(
|
||||||
final watch = Stopwatch()..start();
|
RRect.fromRectAndRadius(rect, const Radius.circular(10)),
|
||||||
CameraController? controller;
|
Paint()
|
||||||
try {
|
..color = color.withValues(alpha: 0.12)
|
||||||
final cameras =
|
..style = PaintingStyle.fill,
|
||||||
await availableCameras().timeout(const Duration(seconds: 3));
|
);
|
||||||
if (cameras.isNotEmpty) {
|
canvas.drawRRect(
|
||||||
controller = CameraController(cameras.first, ResolutionPreset.low,
|
RRect.fromRectAndRadius(rect, const Radius.circular(10)),
|
||||||
enableAudio: false);
|
Paint()
|
||||||
await controller.initialize().timeout(const Duration(seconds: 5));
|
..color = color
|
||||||
await controller.takePicture().timeout(const Duration(seconds: 5));
|
..strokeWidth = 3
|
||||||
}
|
..style = PaintingStyle.stroke,
|
||||||
} catch (_) {
|
);
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 16));
|
|
||||||
} finally {
|
|
||||||
await controller?.dispose();
|
|
||||||
}
|
|
||||||
watch.stop();
|
|
||||||
return watch.elapsedMilliseconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearRuns() async {
|
final label =
|
||||||
final prefs = await SharedPreferences.getInstance();
|
'${ObstacleAnalyzer.spokenLabel(detection.label)} ${(detection.confidence * 100).round()}% ${detection.directionName}';
|
||||||
await prefs.remove(_runsKey);
|
final textPainter = TextPainter(
|
||||||
setState(() => _runs = const []);
|
text: TextSpan(
|
||||||
|
text: label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
maxLines: 1,
|
||||||
|
)..layout(maxWidth: size.width - 24);
|
||||||
|
|
||||||
|
final labelRect = Rect.fromLTWH(
|
||||||
|
rect.left.clamp(8.0, size.width - textPainter.width - 16),
|
||||||
|
(rect.top - textPainter.height - 10).clamp(8.0, size.height - 32),
|
||||||
|
textPainter.width + 14,
|
||||||
|
textPainter.height + 8,
|
||||||
|
);
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(labelRect, const Radius.circular(8)),
|
||||||
|
Paint()..color = color.withValues(alpha: 0.92),
|
||||||
|
);
|
||||||
|
textPainter.paint(canvas, labelRect.topLeft + const Offset(7, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
bool shouldRepaint(covariant _DetectionOverlayPainter oldDelegate) {
|
||||||
final hasRealModel = _models.any((model) => model.endsWith('.tflite'));
|
return oldDelegate.detection != detection;
|
||||||
return _Page(
|
|
||||||
title: 'AI Benchmark',
|
|
||||||
subtitle: 'Capture, model, notification text, and TTS timing',
|
|
||||||
child: ListView(
|
|
||||||
children: [
|
|
||||||
DropdownButtonFormField<String>(
|
|
||||||
value: _selectedModel,
|
|
||||||
decoration: const InputDecoration(labelText: 'Model file'),
|
|
||||||
items: [
|
|
||||||
for (final model in _models)
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: model, child: Text(model.split('/').last))
|
|
||||||
],
|
|
||||||
onChanged: _setModel,
|
|
||||||
),
|
|
||||||
if (!hasRealModel) ...[
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
const _StatusBox(
|
|
||||||
success: false,
|
|
||||||
message:
|
|
||||||
'Belum ada file .tflite di assets/models. Taruh 3-5 model di folder itu, lalu restart app untuk muncul di dropdown.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: _running ? null : _runBenchmark,
|
|
||||||
icon: _running
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
: const Icon(Icons.play_arrow),
|
|
||||||
label: Text(_running ? 'Running benchmark...' : 'Run benchmark'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _clearRuns,
|
|
||||||
icon: const Icon(Icons.delete_outline),
|
|
||||||
label: const Text('Clear log')),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
for (final run in _runs) _BenchmarkCard(run: run),
|
|
||||||
if (_runs.isEmpty)
|
|
||||||
const _EmptyPanel(
|
|
||||||
icon: Icons.speed,
|
|
||||||
title: 'Belum Ada Log',
|
|
||||||
message:
|
|
||||||
'Klik Run benchmark untuk mencatat waktu capture, model/inference, text notification, dan TTS.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BenchmarkCard extends StatelessWidget {
|
|
||||||
final Map<String, dynamic> run;
|
|
||||||
const _BenchmarkCard({required this.run});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final time = DateTime.tryParse(run['time']?.toString() ?? '')?.toLocal();
|
|
||||||
return Card(
|
|
||||||
elevation: 0,
|
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
side: const BorderSide(color: Color(0xFFE2E8F0))),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
time == null
|
|
||||||
? 'Benchmark run'
|
|
||||||
: '${_two(time.hour)}:${_two(time.minute)}:${_two(time.second)}',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w800)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Model: ${run['model'].toString().split('/').last} (${run['modelLoaded'] == true ? 'loaded' : 'fallback'})'),
|
|
||||||
Text('Capture: ${run['captureMs']} ms'),
|
|
||||||
Text('Model/inference: ${run['inferenceMs']} ms'),
|
|
||||||
Text('Notification text: ${run['notificationMs']} ms'),
|
|
||||||
Text('TTS start: ${run['ttsMs']} ms'),
|
|
||||||
Text('Result: ${run['label']} ${run['direction']}'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -533,88 +399,3 @@ class _Pill extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatusBox extends StatelessWidget {
|
|
||||||
final bool success;
|
|
||||||
final String message;
|
|
||||||
const _StatusBox({required this.success, required this.message});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Text(message,
|
|
||||||
style: TextStyle(
|
|
||||||
color: success
|
|
||||||
? const Color(0xFF166534)
|
|
||||||
: const Color(0xFF991B1B))),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EmptyPanel extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final String message;
|
|
||||||
const _EmptyPanel(
|
|
||||||
{required this.icon, required this.title, required this.message});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
constraints: const BoxConstraints(minHeight: 180),
|
|
||||||
padding: const EdgeInsets.all(18),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFF8FAFC),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: const Color(0xFFE2E8F0))),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: const Color(0xFF64748B), size: 48),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(title,
|
|
||||||
style:
|
|
||||||
const TextStyle(fontWeight: FontWeight.w800, fontSize: 18)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(message, textAlign: TextAlign.center),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<List<String>> _discoverTfliteModels() async {
|
|
||||||
try {
|
|
||||||
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
|
|
||||||
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
|
|
||||||
final models = manifest.keys
|
|
||||||
.where((key) =>
|
|
||||||
key.startsWith('assets/models/') && key.endsWith('.tflite'))
|
|
||||||
.toList()
|
|
||||||
..sort();
|
|
||||||
return models;
|
|
||||||
} catch (_) {
|
|
||||||
return const [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _two(int value) => value.toString().padLeft(2, '0');
|
|
||||||
|
|
||||||
void _snack(BuildContext context, String message) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(SnackBar(content: Text(message)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,149 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class FeaturePage extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final Widget child;
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
|
const FeaturePage({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.child,
|
||||||
|
this.trailing,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF0F172A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(color: Color(0xFF64748B)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) trailing!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(child: child),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FeatureEmptyPanel extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String message;
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
|
const FeatureEmptyPanel({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.message,
|
||||||
|
this.action,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 360),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 48, color: const Color(0xFF64748B)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Color(0xFF64748B), height: 1.35),
|
||||||
|
),
|
||||||
|
if (action != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
action!,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FeatureErrorPanel extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final VoidCallback? onRetry;
|
||||||
|
|
||||||
|
const FeatureErrorPanel({
|
||||||
|
super.key,
|
||||||
|
required this.message,
|
||||||
|
this.onRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFEF2F2),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: const Color(0xFFFECACA)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, color: Color(0xFFDC2626), size: 34),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Color(0xFF991B1B), height: 1.35),
|
||||||
|
),
|
||||||
|
if (onRetry != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Coba lagi'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user