From b8ad8df993425b2555b7a11f04525157aca4020e Mon Sep 17 00:00:00 2001 From: Wowieee4 Date: Tue, 26 May 2026 05:52:12 +0700 Subject: [PATCH] ADD ALOT OF POLISHING AND UPDATES... --- .gitignore | 11 + .../walkguide/controller/CallController.java | 106 +- .../controller/GuardianController.java | 2 +- .../controller/PairingController.java | 20 +- .../dto/request/InviteUserRequest.java | 11 +- .../dto/response/PairingCodeResponse.java | 14 + .../dto/response/PairingStatusResponse.java | 2 + .../main/java/com/walkguide/entity/User.java | 6 + .../walkguide/repository/UserRepository.java | 1 + .../service/CallNotificationService.java | 73 + .../walkguide/service/LocationService.java | 10 + .../com/walkguide/service/PairingService.java | 97 +- .../src/main/resources/application-dev.yml | 18 +- .../V17__add_expiring_pairing_codes.sql | 9 + .../demo/src/main/resources/openapi.yaml | 22 +- .../controller/CallControllerTest.java | 348 +-- .../controller/PairingControllerTest.java | 4 +- .../service/CallNotificationServiceTest.java | 128 + .../service/LocationServiceTest.java | 50 +- .../walkguide/service/PairingServiceTest.java | 46 +- walkguide-mobile/walkguide_app/.gitignore | 10 + .../android/app/build.gradle.kts | 4 + .../walkguide_app/assets/models/labels.txt | 78 +- .../walkguide_app/lib/app/app.dart | 100 +- .../walkguide_app/lib/app/router.dart | 80 +- .../lib/core/ai/obstacle_analyzer.dart | 61 +- .../lib/core/ai/yolo_detector.dart | 226 +- .../walkguide_app/lib/core/api_service.dart | 17 +- .../lib/core/errors/friendly_error.dart | 113 + .../lib/core/services/websocket_service.dart | 2 +- .../activity_log/activity_log_screen.dart | 37 +- .../ai_benchmark/ai_benchmark_screen.dart | 290 ++ .../auth/data/auth_repository_impl.dart | 25 +- .../lib/features/auth/login_screen.dart | 222 +- .../auth/presentation/login_screen.dart | 2 +- .../lib/features/auth/register_screen.dart | 220 +- .../lib/features/auth/splash_screen.dart | 43 +- .../guardian_activity_log_screen.dart | 108 +- .../guardian_ai_config_screen.dart | 18 +- .../guardian_map_screen.dart | 301 ++ .../guardian_dashboard/guardian_screens.dart | 25 +- .../guardian_send_notification_screen.dart | 108 + .../guardian_settings_screen.dart | 122 + .../guardian_tools_screen.dart | 199 ++ .../navigation_mode_screen.dart | 36 +- .../notifications/notification_screen.dart | 85 +- .../lib/features/pairing/pairing_screens.dart | 322 ++- .../walkguide_app/lib/features/screens.dart | 2412 ----------------- .../server_connect/server_connect_server.dart | 32 +- .../settings/user_settings_screen.dart | 109 +- .../lib/features/sos/sos_screen.dart | 95 +- .../walk_guide/walk_guide_screen.dart | 425 +-- .../lib/shared/widgets/feature_page.dart | 149 + 53 files changed, 3194 insertions(+), 3860 deletions(-) create mode 100644 walkguide-backend/demo/src/main/java/com/walkguide/dto/response/PairingCodeResponse.java create mode 100644 walkguide-backend/demo/src/main/java/com/walkguide/service/CallNotificationService.java create mode 100644 walkguide-backend/demo/src/main/resources/db/migration/V17__add_expiring_pairing_codes.sql create mode 100644 walkguide-backend/demo/src/test/java/com/walkguide/service/CallNotificationServiceTest.java create mode 100644 walkguide-mobile/walkguide_app/lib/core/errors/friendly_error.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/ai_benchmark/ai_benchmark_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_map_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_send_notification_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_settings_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_tools_screen.dart delete mode 100644 walkguide-mobile/walkguide_app/lib/features/screens.dart create mode 100644 walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart diff --git a/.gitignore b/.gitignore index 3fa0349..49b8309 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,14 @@ walkguide-backend/demo/hs_err_pid*.log # Android SDK path (generated by Android Studio) 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 + diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java b/walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java index 34a0dfb..36d35b5 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java @@ -4,12 +4,9 @@ import com.walkguide.dto.ApiResponse; import com.walkguide.dto.request.CallNotifyRequest; import com.walkguide.dto.request.CallTokenRequest; 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.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.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; @@ -17,23 +14,13 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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; -/** - * 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 @RequestMapping("/api/v1/shared/call") @RequiredArgsConstructor @@ -43,17 +30,10 @@ import java.util.Map; public class CallController { private final AgoraTokenService agoraTokenService; - private final FcmService fcmService; - private final UserRepository userRepository; + private final CallNotificationService callNotificationService; - /** - * Generate Agora RTC token untuk call session. - * Dipanggil oleh CALLER sebelum mulai call. - * - * POST /api/v1/shared/call/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> generateToken( @Valid @RequestBody CallTokenRequest req) { @@ -66,84 +46,24 @@ public class CallController { 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") - @Operation(summary = "Notify receiver of incoming call", - description = "Kirim FCM push notification ke receiver agar join Agora channel yang sama") + @Operation(summary = "Notify receiver of incoming call") public ResponseEntity> notifyCall( @Valid @RequestBody CallNotifyRequest req) { Long callerId = SecurityHelper.getCurrentUserId(); - - // Ambil info caller untuk notifikasi - 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 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")); + String message = callNotificationService.notifyIncomingCall(callerId, req); + return ResponseEntity.ok(ApiResponse.ok(null, message)); } - /** - * 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") @Operation(summary = "Notify end of call") public ResponseEntity> endCall( @RequestBody Map body) { - Long callerId = SecurityHelper.getCurrentUserId(); - Long otherId = body.get("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)) - ); - } - }); - } + Long callerId = SecurityHelper.getCurrentUserId(); + Long otherId = body.get("otherId"); + callNotificationService.notifyCallEnded(callerId, otherId); return ResponseEntity.ok(ApiResponse.ok(null, "Call ended")); } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/controller/GuardianController.java b/walkguide-backend/demo/src/main/java/com/walkguide/controller/GuardianController.java index ac978ab..ab42590 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/controller/GuardianController.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/controller/GuardianController.java @@ -51,7 +51,7 @@ public class GuardianController { Long guardianId = SecurityHelper.getCurrentUserId(); // Perlu ambil userId dulu — delegasikan ke service return ResponseEntity.ok(ApiResponse.ok( - locationService.getLocationHistory(guardianId, + locationService.getLocationHistoryForGuardian(guardianId, PageRequest.of(page, size, Sort.by("createdAt").descending())), "Riwayat lokasi")); } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/controller/PairingController.java b/walkguide-backend/demo/src/main/java/com/walkguide/controller/PairingController.java index 0898510..997025b 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/controller/PairingController.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/controller/PairingController.java @@ -2,9 +2,11 @@ package com.walkguide.controller; import com.walkguide.dto.ApiResponse; import com.walkguide.dto.request.*; +import com.walkguide.dto.response.PairingCodeResponse; import com.walkguide.dto.response.PairingStatusResponse; import com.walkguide.security.SecurityHelper; import com.walkguide.service.PairingService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -16,12 +18,26 @@ public class PairingController { private final PairingService pairingService; + @GetMapping("/code") + public ResponseEntity> code() { + return ResponseEntity.ok(ApiResponse.ok( + pairingService.getOrCreatePairingCode(SecurityHelper.getCurrentUserId()), + "Pairing code aktif")); + } + + @PostMapping("/code/regenerate") + public ResponseEntity> regenerateCode() { + return ResponseEntity.ok(ApiResponse.ok( + pairingService.regeneratePairingCode(SecurityHelper.getCurrentUserId()), + "Pairing code baru dibuat")); + } + @PostMapping("/invite") public ResponseEntity> invite( - @RequestBody InviteUserRequest req) { + @Valid @RequestBody InviteUserRequest req) { Long guardianId = SecurityHelper.getCurrentUserId(); return ResponseEntity.ok(ApiResponse.ok( - pairingService.inviteUser(guardianId, req.getUniqueUserId()), + pairingService.inviteUser(guardianId, req.resolveSubmittedCode()), "Undangan dikirim ke user")); } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/dto/request/InviteUserRequest.java b/walkguide-backend/demo/src/main/java/com/walkguide/dto/request/InviteUserRequest.java index 599d14d..b3cd951 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/dto/request/InviteUserRequest.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/dto/request/InviteUserRequest.java @@ -5,7 +5,16 @@ import lombok.Data; @Data public class InviteUserRequest { - @NotBlank(message = "User ID tidak boleh kosong") @Size(min = 12, max = 12, message = "User ID harus tepat 12 karakter") 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(); + } } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/PairingCodeResponse.java b/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/PairingCodeResponse.java new file mode 100644 index 0000000..6f6e986 --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/PairingCodeResponse.java @@ -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; +} diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/PairingStatusResponse.java b/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/PairingStatusResponse.java index 8fd8d9f..1148fbe 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/PairingStatusResponse.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/PairingStatusResponse.java @@ -12,6 +12,8 @@ public class PairingStatusResponse { private String pairedWithName; // nama Guardian (untuk User) atau nama User (untuk Guardian) private String pairedWithEmail; 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 respondedAt; } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/entity/User.java b/walkguide-backend/demo/src/main/java/com/walkguide/entity/User.java index 50668c5..606cd61 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/entity/User.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/entity/User.java @@ -29,6 +29,12 @@ public class User { @Column(name = "unique_user_id", unique = true, length = 12) 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) private String displayName; diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/repository/UserRepository.java b/walkguide-backend/demo/src/main/java/com/walkguide/repository/UserRepository.java index 7c64264..de38927 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/repository/UserRepository.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/repository/UserRepository.java @@ -9,5 +9,6 @@ import java.util.Optional; public interface UserRepository extends JpaRepository { Optional findByEmail(String email); Optional findByUniqueUserId(String uniqueUserId); + Optional findByPairingCode(String pairingCode); boolean existsByEmail(String email); } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/CallNotificationService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/CallNotificationService.java new file mode 100644 index 0000000..fc45d02 --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/CallNotificationService.java @@ -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 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)) + ); + }); + } +} diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/LocationService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/LocationService.java index 2189f30..6db75fd 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/LocationService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/LocationService.java @@ -6,6 +6,7 @@ import com.walkguide.entity.LocationHistory; import com.walkguide.entity.User; import com.walkguide.enums.ActivityLogType; import com.walkguide.enums.PairingStatus; +import com.walkguide.exception.PairingException; import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.repository.*; import com.walkguide.websocket.LocationBroadcaster; @@ -79,6 +80,15 @@ public class LocationService { .map(this::toResponse); } + public Page 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 ========== private void checkGeofence(Long userId, double lat, double lng) { diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/PairingService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/PairingService.java index bb4db44..69f0445 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/PairingService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/PairingService.java @@ -1,6 +1,7 @@ package com.walkguide.service; import com.walkguide.dto.response.PairingStatusResponse; +import com.walkguide.dto.response.PairingCodeResponse; import com.walkguide.entity.*; import com.walkguide.enums.*; import com.walkguide.exception.PairingException; @@ -10,7 +11,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.security.SecureRandom; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; @@ -26,8 +29,46 @@ public class PairingService { private final ActivityLogService activityLogService; 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 - 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 if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) { 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) .orElseThrow(() -> new ResourceNotFoundException("Guardian tidak ditemukan")); - User user = userRepository.findByUniqueUserId(uniqueUserId) - .orElseThrow(() -> new ResourceNotFoundException("User dengan ID '" + uniqueUserId + "' tidak ditemukan")); + User user = resolveUserByPairingCode(submittedCode); if (!"ROLE_USER".equals(user.getRole())) { throw new PairingException("ID tersebut bukan milik User. Pastikan kamu memasukkan ID yang benar."); @@ -55,6 +95,10 @@ public class PairingService { .build(); pairing = pairingRelationRepository.save(pairing); + user.setPairingCode(null); + user.setPairingCodeExpiresAt(null); + userRepository.save(user); + // Kirim FCM ke user fcmService.sendToToken(user.getFcmToken(), "Pairing Request", @@ -199,6 +243,51 @@ public class PairingService { .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) { String pairedWithName = "GUARDIAN".equals(viewerRole) ? user.getDisplayName() : guardian.getDisplayName(); @@ -211,6 +300,8 @@ public class PairingService { .pairedWithName(pairedWithName) .pairedWithEmail(pairedWithEmail) .uniqueUserId(user.getUniqueUserId()) + .pairingCode(user.getPairingCode()) + .pairingCodeExpiresAt(user.getPairingCodeExpiresAt()) .invitedAt(p.getInvitedAt()) .respondedAt(p.getRespondedAt()) .build(); diff --git a/walkguide-backend/demo/src/main/resources/application-dev.yml b/walkguide-backend/demo/src/main/resources/application-dev.yml index 57096ce..2e355b6 100644 --- a/walkguide-backend/demo/src/main/resources/application-dev.yml +++ b/walkguide-backend/demo/src/main/resources/application-dev.yml @@ -4,11 +4,11 @@ # atau set env: SPRING_PROFILES_ACTIVE=dev # =================================================== -spring: - datasource: - url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001} - username: ${DB_USERNAME:5803024001} - password: ${DB_PASSWORD:pw5803024001} +spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} jpa: show-sql: true @@ -16,9 +16,9 @@ spring: hibernate: format_sql: true -jwt: - secret: ${JWT_SECRET:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970} - expiration: 86400000 +jwt: + secret: ${JWT_SECRET} + expiration: ${JWT_EXPIRATION:86400000} agora: app-id: ${AGORA_APP_ID:} @@ -28,4 +28,4 @@ logging: level: com.walkguide: DEBUG org.springframework.messaging: DEBUG - org.springframework.web.socket: DEBUG \ No newline at end of file + org.springframework.web.socket: DEBUG diff --git a/walkguide-backend/demo/src/main/resources/db/migration/V17__add_expiring_pairing_codes.sql b/walkguide-backend/demo/src/main/resources/db/migration/V17__add_expiring_pairing_codes.sql new file mode 100644 index 0000000..be5a287 --- /dev/null +++ b/walkguide-backend/demo/src/main/resources/db/migration/V17__add_expiring_pairing_codes.sql @@ -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); diff --git a/walkguide-backend/demo/src/main/resources/openapi.yaml b/walkguide-backend/demo/src/main/resources/openapi.yaml index f2c469a..f2adc26 100644 --- a/walkguide-backend/demo/src/main/resources/openapi.yaml +++ b/walkguide-backend/demo/src/main/resources/openapi.yaml @@ -4,7 +4,8 @@ info: version: 1.0.0 description: Design contract for WalkGuide Flutter and Spring Boot integration. servers: - - url: http://localhost:8080/api/v1 + - url: https://api.walkguide.example/api/v1 + description: Production deployment URL placeholder security: - bearerAuth: [] components: @@ -37,9 +38,16 @@ components: role: { type: string, enum: [USER, GUARDIAN] } PairingInviteRequest: type: object - required: [uniqueUserId] + required: [pairingCode] 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: type: object required: [pairingId, accept] @@ -107,6 +115,14 @@ paths: schema: { $ref: "#/components/schemas/PairingInviteRequest" } responses: "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: post: requestBody: diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/controller/CallControllerTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/controller/CallControllerTest.java index 0e027b5..3b3f16d 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/controller/CallControllerTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/controller/CallControllerTest.java @@ -4,13 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.walkguide.dto.request.CallNotifyRequest; import com.walkguide.dto.request.CallTokenRequest; 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.JwtAuthFilter; import com.walkguide.security.SecurityHelper; import com.walkguide.service.AgoraTokenService; -import com.walkguide.service.FcmService; -import org.junit.jupiter.api.BeforeEach; +import com.walkguide.service.CallNotificationService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; 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.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import com.walkguide.security.JwtAuthFilter; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import java.util.Map; -import java.util.Optional; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; +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.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @AutoConfigureMockMvc(addFilters = false) @WebMvcTest(CallController.class) @@ -38,7 +38,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @DisplayName("CallController Unit Tests") class CallControllerTest { - @MockBean private JwtAuthFilter jwtAuthFilter; + @MockBean + private JwtAuthFilter jwtAuthFilter; @Autowired private MockMvc mockMvc; @@ -50,35 +51,10 @@ class CallControllerTest { private AgoraTokenService agoraTokenService; @MockBean - private FcmService fcmService; - - @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 ===== + private CallNotificationService callNotificationService; @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 { try (MockedStatic sh = mockStatic(SecurityHelper.class)) { sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); @@ -88,7 +64,7 @@ class CallControllerTest { AgoraTokenResponse tokenResp = AgoraTokenResponse.builder() .token("agora-rtc-token-xyz") - .channelName("call_1_2_1234567890") + .channelName("call_1_2") .uid(1001) .build(); @@ -102,34 +78,24 @@ class CallControllerTest { .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("Token Agora berhasil digenerate")) .andExpect(jsonPath("$.data.token").value("agora-rtc-token-xyz")) - .andExpect(jsonPath("$.data.channelName").value("call_1_2_1234567890")); - - verify(agoraTokenService).generateToken(1L, 2L); + .andExpect(jsonPath("$.data.channelName").value("call_1_2")); } } @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 { - try (MockedStatic sh = mockStatic(SecurityHelper.class)) { - sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); + CallTokenRequest req = new CallTokenRequest(); - // CallTokenRequest dengan receiverId null — @Valid harus menolak - CallTokenRequest req = new CallTokenRequest(); - // receiverId tidak di-set (null) - - mockMvc.perform(post("/api/v1/shared/call/token") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(req))) - .andExpect(status().isBadRequest()); - - verify(agoraTokenService, never()).generateToken(anyLong(), anyLong()); - } + mockMvc.perform(post("/api/v1/shared/call/token") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); } @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 { try (MockedStatic sh = mockStatic(SecurityHelper.class)) { sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); @@ -148,21 +114,17 @@ class CallControllerTest { } } - // ===== NOTIFY CALL ===== - @Test - @DisplayName("POST /api/v1/shared/call/notify - receiver punya FCM token harus kirim notifikasi") - void notifyCall_receiverHasFcmToken_shouldReturn200AndSendFcm() throws Exception { + @DisplayName("POST /api/v1/shared/call/notify - delegates to call notification service") + void notifyCall_validRequest_shouldReturnServiceMessage() throws Exception { try (MockedStatic sh = mockStatic(SecurityHelper.class)) { sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); - - when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller)); - when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver)); - doNothing().when(fcmService).sendHighPriority(anyString(), anyString(), anyString(), anyMap()); + when(callNotificationService.notifyIncomingCall(eq(1L), any(CallNotifyRequest.class))) + .thenReturn("Notifikasi panggilan berhasil dikirim"); CallNotifyRequest req = new CallNotifyRequest(); req.setReceiverId(2L); - req.setChannelName("call_1_2_1234567890"); + req.setChannelName("call_1_2"); req.setAgoraToken("agora-token-xyz"); req.setReceiverUid(1002); @@ -174,260 +136,40 @@ class CallControllerTest { .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("Notifikasi panggilan berhasil dikirim")); - verify(fcmService).sendHighPriority( - eq("fcm-receiver-token"), - eq("📞 Panggilan Masuk"), - contains("Caller User"), - anyMap() - ); + verify(callNotificationService).notifyIncomingCall(eq(1L), any(CallNotifyRequest.class)); } } @Test - @DisplayName("POST /api/v1/shared/call/notify - receiver tidak punya FCM token harus return 200 tanpa FCM") - void notifyCall_receiverNoFcmToken_shouldReturn200WithWarningMessage() throws Exception { - try (MockedStatic sh = mockStatic(SecurityHelper.class)) { - sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); + @DisplayName("POST /api/v1/shared/call/notify - validation failure does not call service") + void notifyCall_invalidRequest_shouldReturn400() throws Exception { + CallNotifyRequest req = new CallNotifyRequest(); + req.setChannelName("call_1_2"); - User receiverNoFcm = User.builder() - .id(2L) - .email("receiver@test.com") - .displayName("Receiver") - .fcmToken(null) // tidak punya FCM token - .build(); + mockMvc.perform(post("/api/v1/shared/call/notify") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); - when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller)); - when(userRepository.findById(2L)).thenReturn(Optional.of(receiverNoFcm)); - - CallNotifyRequest req = new CallNotifyRequest(); - req.setReceiverId(2L); - req.setChannelName("call_1_2_abc"); - req.setAgoraToken("token-xyz"); - 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)")); - - // FCM tidak boleh dipanggil karena tidak ada token - verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap()); - } + verify(callNotificationService, never()).notifyIncomingCall(any(), any()); } @Test - @DisplayName("POST /api/v1/shared/call/notify - receiver FCM token blank harus return 200 tanpa FCM") - void notifyCall_receiverBlankFcmToken_shouldReturn200WithoutFcm() throws Exception { + @DisplayName("POST /api/v1/shared/call/end - delegates to call notification service") + void endCall_validOtherId_shouldDelegateToService() throws Exception { try (MockedStatic sh = mockStatic(SecurityHelper.class)) { 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 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 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 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 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 body = Map.of("otherId", 2L); - mockMvc.perform(post("/api/v1/shared/call/end") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(body))) + .content(objectMapper.writeValueAsString(Map.of("otherId", 2L)))) .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.message").value("Call ended")); - verify(fcmService).sendToToken( - 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 sh = mockStatic(SecurityHelper.class)) { - sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); - - Map 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 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 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 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 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")); + verify(callNotificationService).notifyCallEnded(1L, 2L); } } } diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/controller/PairingControllerTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/controller/PairingControllerTest.java index 76b9958..fe48e49 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/controller/PairingControllerTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/controller/PairingControllerTest.java @@ -92,9 +92,9 @@ class PairingControllerTest { sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L); 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")); mockMvc.perform(post("/api/v1/shared/pairing/invite") diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/service/CallNotificationServiceTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/service/CallNotificationServiceTest.java new file mode 100644 index 0000000..19168bc --- /dev/null +++ b/walkguide-backend/demo/src/test/java/com/walkguide/service/CallNotificationServiceTest.java @@ -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()); + } +} diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/service/LocationServiceTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/service/LocationServiceTest.java index ec2f655..b11c2b6 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/service/LocationServiceTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/service/LocationServiceTest.java @@ -5,8 +5,9 @@ import com.walkguide.dto.response.LocationResponse; import com.walkguide.entity.GeofenceConfig; import com.walkguide.entity.LocationHistory; import com.walkguide.entity.PairingRelation; -import com.walkguide.entity.User; -import com.walkguide.enums.PairingStatus; +import com.walkguide.entity.User; +import com.walkguide.enums.PairingStatus; +import com.walkguide.exception.PairingException; import com.walkguide.repository.*; import com.walkguide.websocket.LocationBroadcaster; import org.junit.jupiter.api.BeforeEach; @@ -217,9 +218,9 @@ class LocationServiceTest { // ===== getLocationHistory TESTS ===== - @Test - @DisplayName("getLocationHistory - harus return halaman lokasi sesuai pageable") - void getLocationHistory_shouldReturnPagedLocations() { + @Test + @DisplayName("getLocationHistory - harus return halaman lokasi sesuai pageable") + void getLocationHistory_shouldReturnPagedLocations() { Pageable pageable = PageRequest.of(0, 10); Page page = new PageImpl<>(List.of(sampleLocation), pageable, 1); @@ -229,10 +230,39 @@ class LocationServiceTest { assertThat(result).isNotNull(); assertThat(result.getContent()).hasSize(1); - assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257); - } - - // ===== haversineMeters TESTS ===== + 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 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 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 ===== @Test @DisplayName("haversineMeters - titik sama: jarak harus 0") @@ -256,4 +286,4 @@ class LocationServiceTest { double d2 = LocationService.haversineMeters(-7.300, 112.800, -7.257, 112.752); assertThat(d1).isCloseTo(d2, within(0.001)); } -} \ No newline at end of file +} diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/service/PairingServiceTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/service/PairingServiceTest.java index 24e5e93..498421a 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/service/PairingServiceTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/service/PairingServiceTest.java @@ -15,7 +15,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; 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.mockito.ArgumentMatchers.*; @@ -52,10 +53,12 @@ class PairingServiceTest { .id(2L) .email("user@test.com") .role("ROLE_USER") - .displayName("User Test") - .uniqueUserId("ABC123DEF456") - .fcmToken("user-fcm-token") - .build(); + .displayName("User Test") + .uniqueUserId("ABC123DEF456") + .pairingCode("AB12CD34") + .pairingCodeExpiresAt(LocalDateTime.now().plusMinutes(10)) + .fcmToken("user-fcm-token") + .build(); } // ===== INVITE USER TESTS ===== @@ -66,7 +69,7 @@ class PairingServiceTest { when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false); when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false); 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.save(any(PairingRelation.class))).thenAnswer(inv -> { PairingRelation p = inv.getArgument(0); @@ -74,7 +77,7 @@ class PairingServiceTest { return p; }); - PairingStatusResponse result = pairingService.inviteUser(1L, "ABC123DEF456"); + PairingStatusResponse result = pairingService.inviteUser(1L, "AB12CD34"); assertThat(result).isNotNull(); 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.PENDING)).thenReturn(false); 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")) .isInstanceOf(ResourceNotFoundException.class) @@ -123,17 +126,20 @@ class PairingServiceTest { @Test @DisplayName("inviteUser - target bukan ROLE_USER harus throw PairingException") void inviteUser_targetNotUser_shouldThrow() { - User anotherGuardian = User.builder() - .id(3L).role("ROLE_GUARDIAN").uniqueUserId("GRD000000001").build(); + User anotherGuardian = User.builder() + .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.PENDING)).thenReturn(false); when(userRepository.findById(1L)).thenReturn(Optional.of(guardian)); - when(userRepository.findByUniqueUserId("GRD000000001")).thenReturn(Optional.of(anotherGuardian)); - - assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD000000001")) - .isInstanceOf(PairingException.class) - .hasMessageContaining("bukan milik User"); + when(userRepository.findByPairingCode("GRD00001")).thenReturn(Optional.of(anotherGuardian)); + + assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD00001")) + .isInstanceOf(PairingException.class) + .hasMessageContaining("bukan milik User"); } @Test @@ -142,12 +148,12 @@ class PairingServiceTest { when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false); when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false); 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); - assertThatThrownBy(() -> pairingService.inviteUser(1L, "ABC123DEF456")) - .isInstanceOf(PairingException.class) - .hasMessageContaining("sudah dipair dengan Guardian lain"); + assertThatThrownBy(() -> pairingService.inviteUser(1L, "AB12CD34")) + .isInstanceOf(PairingException.class) + .hasMessageContaining("sudah dipair dengan Guardian lain"); } // ===== RESPOND TO PAIRING TESTS ===== @@ -211,4 +217,4 @@ class PairingServiceTest { .isInstanceOf(PairingException.class) .hasMessageContaining("sudah direspons"); } -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/.gitignore b/walkguide-mobile/walkguide_app/.gitignore index 0e03039..7d94ac0 100644 --- a/walkguide-mobile/walkguide_app/.gitignore +++ b/walkguide-mobile/walkguide_app/.gitignore @@ -49,3 +49,13 @@ hs_err_pid*.log # Android SDK path (generated by Android Studio) 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/ diff --git a/walkguide-mobile/walkguide_app/android/app/build.gradle.kts b/walkguide-mobile/walkguide_app/android/app/build.gradle.kts index ae4ef59..24d296b 100644 --- a/walkguide-mobile/walkguide_app/android/app/build.gradle.kts +++ b/walkguide-mobile/walkguide_app/android/app/build.gradle.kts @@ -31,6 +31,10 @@ android { versionName = flutter.versionName } + androidResources { + noCompress += listOf("tflite", "lite") + } + buildTypes { release { // TODO: Add your own signing config for the release build. diff --git a/walkguide-mobile/walkguide_app/assets/models/labels.txt b/walkguide-mobile/walkguide_app/assets/models/labels.txt index a7ffc28..941cb4e 100644 --- a/walkguide-mobile/walkguide_app/assets/models/labels.txt +++ b/walkguide-mobile/walkguide_app/assets/models/labels.txt @@ -1,10 +1,80 @@ person +bicycle car motorcycle -bicycle +airplane bus +train truck -chair +boat +traffic light +fire hydrant +stop sign +parking meter bench -door -stairs +bird +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 diff --git a/walkguide-mobile/walkguide_app/lib/app/app.dart b/walkguide-mobile/walkguide_app/lib/app/app.dart index 6e324e3..ebf00e5 100644 --- a/walkguide-mobile/walkguide_app/lib/app/app.dart +++ b/walkguide-mobile/walkguide_app/lib/app/app.dart @@ -19,37 +19,81 @@ class WalkGuideApp extends StatelessWidget { debugShowCheckedModeBanner: false, routerConfig: appRouter, theme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed(seedColor: seed), - scaffoldBackgroundColor: const Color(0xFFF8FAFC), - textTheme: GoogleFonts.interTextTheme(), - appBarTheme: const AppBarTheme( - centerTitle: false, - backgroundColor: Colors.white, - foregroundColor: Color(0xFF0F172A), - elevation: 0, - surfaceTintColor: Colors.white, - ), - filledButtonTheme: FilledButtonThemeData( - style: FilledButton.styleFrom( - backgroundColor: seed, - foregroundColor: Colors.white, - minimumSize: const Size(0, 46), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: seed, + brightness: Brightness.light, ), - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + scaffoldBackgroundColor: const Color(0xFFF4F7FB), + textTheme: GoogleFonts.interTextTheme(), + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: ZoomPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(), + }, ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + appBarTheme: const AppBarTheme( + centerTitle: false, + backgroundColor: Color(0xFFF4F7FB), + foregroundColor: Color(0xFF0F172A), + elevation: 0, + 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( + style: FilledButton.styleFrom( + backgroundColor: seed, + foregroundColor: Colors.white, + minimumSize: const Size(0, 50), + 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( + filled: true, + fillColor: const Color(0xFFF8FAFC), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: seed, width: 1.5), + ), ), - ), ), ), ); diff --git a/walkguide-mobile/walkguide_app/lib/app/router.dart b/walkguide-mobile/walkguide_app/lib/app/router.dart index a5b399d..2ad70af 100644 --- a/walkguide-mobile/walkguide_app/lib/app/router.dart +++ b/walkguide-mobile/walkguide_app/lib/app/router.dart @@ -2,8 +2,33 @@ import 'package:go_router/go_router.dart'; import '../core/constants/app_constants.dart'; 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/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'; final GoRouter appRouter = GoRouter( @@ -25,19 +50,23 @@ final GoRouter appRouter = GoRouter( routes: [ GoRoute( path: '/server-connect', - builder: (_, __) => const ServerConnectScreen()), - GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), - GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), - GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()), + builder: (_, __) => const server_connect.ServerConnectScreen()), 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( builder: (_, __, child) => UserShell(child: child), routes: [ GoRoute( path: '/user/walkguide', - builder: (_, __) => const WalkGuideScreen()), - GoRoute(path: '/user/sos', builder: (_, __) => const SosScreen()), + builder: (_, __) => const walk_guide.WalkGuideScreen()), + GoRoute(path: '/user/sos', builder: (_, __) => const sos.SosScreen()), GoRoute( path: '/user/activity', builder: (_, __) => const activity.ActivityLogScreen()), @@ -46,17 +75,18 @@ final GoRouter appRouter = GoRouter( builder: (_, __) => const notifications.NotificationScreen()), GoRoute( path: '/user/navigation', - builder: (_, __) => const NavigationModeScreen()), + builder: (_, __) => const nav.NavigationModeScreen()), GoRoute( path: '/user/settings', - builder: (_, __) => const UserSettingsScreen()), + builder: (_, __) => const user_settings.UserSettingsScreen()), GoRoute( path: '/user/pairing', - builder: (_, __) => const UserPairingScreen()), - GoRoute(path: '/user/call', builder: (_, __) => const CallScreen()), + builder: (_, __) => const pairing.UserPairingScreen()), + GoRoute( + path: '/user/call', builder: (_, __) => const call.CallScreen()), GoRoute( path: '/user/benchmark', - builder: (_, __) => const AiBenchmarkScreen()), + builder: (_, __) => const benchmark.AiBenchmarkScreen()), ], ), ShellRoute( @@ -64,37 +94,39 @@ final GoRouter appRouter = GoRouter( routes: [ GoRoute( path: '/guardian/dashboard', - builder: (_, __) => const GuardianDashboardScreen()), + builder: (_, __) => const guardian_home.GuardianDashboardScreen()), GoRoute( path: '/guardian/map', - builder: (_, __) => const GuardianMapScreen()), + builder: (_, __) => const guardian_map.GuardianMapScreen()), GoRoute( path: '/guardian/logs', - builder: (_, __) => const GuardianActivityLogScreen()), + builder: (_, __) => + const guardian_logs.GuardianActivityLogScreen()), GoRoute( path: '/guardian/send-notif', - builder: (_, __) => const GuardianSendNotifScreen()), + builder: (_, __) => const guardian_send.GuardianSendNotifScreen()), GoRoute( path: '/guardian/ai-config', - builder: (_, __) => const GuardianAiConfigScreen()), + builder: (_, __) => const guardian_ai.GuardianAiConfigScreen()), GoRoute( path: '/guardian/voice-cmd', - builder: (_, __) => const GuardianVoiceCmdScreen()), + builder: (_, __) => const guardian_tools.GuardianVoiceCmdScreen()), GoRoute( path: '/guardian/shortcuts', - builder: (_, __) => const GuardianShortcutScreen()), + builder: (_, __) => const guardian_tools.GuardianShortcutScreen()), GoRoute( path: '/guardian/geofence', - builder: (_, __) => const GuardianGeofenceScreen()), + builder: (_, __) => const guardian_tools.GuardianGeofenceScreen()), GoRoute( path: '/guardian/pairing', - builder: (_, __) => const GuardianPairingScreen()), + builder: (_, __) => const pairing.GuardianPairingScreen()), GoRoute( path: '/guardian/settings', - builder: (_, __) => const GuardianSettingsScreen()), + builder: (_, __) => + const guardian_settings.GuardianSettingsScreen()), GoRoute( path: '/guardian/benchmark', - builder: (_, __) => const AiBenchmarkScreen()), + builder: (_, __) => const benchmark.AiBenchmarkScreen()), ], ), ], diff --git a/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart b/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart index 8eb6dd6..8e393ad 100644 --- a/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart +++ b/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart @@ -23,12 +23,14 @@ class DetectionResult { final double confidence; final ObstacleDirection direction; final String estimatedDistance; + final BoundingBox? box; const DetectionResult({ required this.label, required this.confidence, required this.direction, required this.estimatedDistance, + this.box, }); String get directionName => direction.name.toUpperCase(); @@ -39,7 +41,7 @@ class DetectionResult { ObstacleDirection.center => 'tengah', 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.right => 'kanan', }; - return 'Hati-hati, ${result.label} di $directionLabel. ' + return 'Hati-hati, ${spokenLabel(result.label)} di $directionLabel. ' 'Jarak ${result.estimatedDistance}.'; } @@ -90,7 +92,12 @@ class ObstacleAnalyzer { final bi = order.indexOf(b.estimatedDistance); final aRank = ai == -1 ? order.length : ai; 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; } @@ -102,15 +109,43 @@ class ObstacleAnalyzer { return detections.where((d) => d.confidence >= threshold).toList(); } - DetectionResult analyzeFallback({ - String label = 'person', - double confidence = 0.86, - }) { - return DetectionResult( - label: label, - confidence: confidence, - direction: ObstacleDirection.center, - estimatedDistance: 'Close (1-2m)', - ); + int _directionRisk(ObstacleDirection direction) { + return switch (direction) { + ObstacleDirection.center => 0, + ObstacleDirection.left => 1, + ObstacleDirection.right => 1, + }; + } + + 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, + }; } } diff --git a/walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart b/walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart index 2931fd0..4d92b62 100644 --- a/walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart +++ b/walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart @@ -18,11 +18,31 @@ class YoloDetector { YoloTensorType? _outputType; bool _ready = false; String? _lastError; + int _lastDecodedCount = 0; + int _lastConfidenceCount = 0; + int _lastObstacleCount = 0; + int _lastKeptCount = 0; + int _lastInferenceMs = 0; + String? _lastBestLabel; + double? _lastBestConfidence; YoloDetector(this._analyzer); bool get isReady => _ready && _runtime != null; 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 init() async { dispose(); @@ -47,55 +67,64 @@ class YoloDetector { _ready = true; } catch (e) { _lastError = e.toString(); - debugPrint('YOLO fallback mode: $e'); + debugPrint('YOLO runtime initialization skipped: $e'); _ready = false; } } Future detect( CameraImage image, { - double confidenceThreshold = 0.45, + double confidenceThreshold = 0.25, }) async { - if (!isReady) return detectFallback(); + if (!isReady) return null; try { + final stopwatch = Stopwatch()..start(); final input = _buildCameraInput(image); final output = Uint8List(_runtime!.outputByteLength); _runtime!.run(input, output); final detections = _decodeDetections(output); + _recordDecoded(detections); final filtered = _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) { _lastError = e.toString(); - debugPrint('YOLO inference fallback: $e'); - return detectFallback(); + debugPrint('YOLO inference skipped: $e'); + return null; } } Future detectSynthetic({ double confidenceThreshold = 0.25, }) async { - if (!isReady) return detectFallback(); + if (!isReady) return null; try { final input = _buildSyntheticInput(); final output = Uint8List(_runtime!.outputByteLength); _runtime!.run(input, output); final detections = _decodeDetections(output); + _recordDecoded(detections); final filtered = _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) { _lastError = e.toString(); - debugPrint('YOLO synthetic fallback: $e'); - return detectFallback(); + debugPrint('YOLO synthetic skipped: $e'); + return null; } } - Future detectFallback() async { - final label = _labels.isNotEmpty ? _labels.first : 'person'; - return _analyzer.analyzeFallback(label: label); - } - void dispose() { _runtime?.close(); _runtime = null; @@ -104,6 +133,26 @@ class YoloDetector { _inputType = null; _outputType = null; _ready = false; + _lastDecodedCount = 0; + _lastConfidenceCount = 0; + _lastObstacleCount = 0; + _lastKeptCount = 0; + _lastInferenceMs = 0; + _lastBestLabel = null; + _lastBestConfidence = null; + } + + void _recordDecoded(List 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) { @@ -292,7 +341,7 @@ class YoloDetector { } if (type == YoloTensorType.float16) { // 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('Unsupported YOLO output type: $type'); @@ -484,19 +533,164 @@ class YoloDetector { confidence: confidence, direction: _analyzer.analyzeDirection(box), estimatedDistance: _analyzer.estimateDistance(box), + box: box, ); } + + List _nonMaxSuppression( + List 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 = []; + + 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 _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 _cocoObstacleLabels = { 0: 'person', 1: 'bicycle', 2: 'car', 3: 'motorcycle', + 4: 'airplane', 5: 'bus', + 6: 'train', 7: 'truck', + 8: 'boat', + 9: 'traffic light', + 10: 'fire hydrant', + 11: 'stop sign', + 12: 'parking meter', 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', + 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 { diff --git a/walkguide-mobile/walkguide_app/lib/core/api_service.dart b/walkguide-mobile/walkguide_app/lib/core/api_service.dart index e68da1b..fdc22a4 100644 --- a/walkguide-mobile/walkguide_app/lib/core/api_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/api_service.dart @@ -1,21 +1,14 @@ import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; // Wajib import ini class ApiService { - static String get baseUrl { - if (kIsWeb) { - // Jika di Chrome/Web, tembak localhost langsung - 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'; - } - } + static const baseUrl = String.fromEnvironment( + 'WALKGUIDE_API_BASE_URL', + defaultValue: 'http://202.46.28.160:8080/api/v1', + ); final Dio _dio = Dio(BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 5), - // Penting buat Web agar tidak kena error CORS di sisi Client headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', @@ -25,4 +18,4 @@ class ApiService { Future post(String path, Map data) async { return await _dio.post(path, data: data); } -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/lib/core/errors/friendly_error.dart b/walkguide-mobile/walkguide_app/lib/core/errors/friendly_error.dart new file mode 100644 index 0000000..14b264f --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/errors/friendly_error.dart @@ -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 runFriendly( + Future 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 runFriendlyAction( + Future 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; + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart index 9ce364e..dc435b6 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart @@ -37,7 +37,7 @@ class WebSocketService { bool get isConnected => _connected; /// Connect ke WebSocket server. - /// Dipanggil setelah login berhasil (dari screens.dart _startPostLoginServices). + /// Dipanggil setelah login berhasil untuk membuka channel realtime. Future connect(String serverUrl) async { await disconnect(); diff --git a/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart b/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart index 378a58b..eff1907 100644 --- a/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../app/injection_container.dart'; +import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/theme/app_colors.dart'; @@ -44,26 +45,22 @@ class _ActivityLogScreenState extends State { _loading = true; _error = null; }); - try { - final res = await _api - .get('/user/activity-logs') - .timeout(const Duration(seconds: 10)); - final list = _extractList(res.data); - final items = list.map(_LogItem.fromJson).toList(); - setState(() { - _items = items; - _applyFilter(_selectedFilter); - }); - } on DioException catch (e) { - setState(() { - _error = e.response?.data?['message']?.toString() ?? - 'Gagal memuat activity log.'; - }); - } catch (e) { - setState(() => _error = 'Timeout / error: $e'); - } finally { - if (mounted) setState(() => _loading = false); - } + await runFriendlyAction( + () async { + final res = await _api + .get('/user/activity-logs') + .timeout(const Duration(seconds: 10)); + final list = _extractList(res.data); + final items = list.map(_LogItem.fromJson).toList(); + setState(() { + _items = items; + _applyFilter(_selectedFilter); + }); + }, + onError: (message) => setState(() => _error = message), + fallback: 'Gagal memuat activity log. Coba refresh lagi.', + ); + if (mounted) setState(() => _loading = false); } List> _extractList(dynamic responseBody) { diff --git a/walkguide-mobile/walkguide_app/lib/features/ai_benchmark/ai_benchmark_screen.dart b/walkguide-mobile/walkguide_app/lib/features/ai_benchmark/ai_benchmark_screen.dart new file mode 100644 index 0000000..4787706 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/ai_benchmark/ai_benchmark_screen.dart @@ -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 createState() => _AiBenchmarkScreenState(); +} + +class _AiBenchmarkScreenState extends State { + static const _runsKey = 'ai_benchmark_runs'; + List _models = const []; + String _selectedModel = AppConstants.yoloModelPath; + List> _runs = const []; + bool _running = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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.from(jsonDecode(e) as Map)) + .toList() + .reversed + .toList(); + }); + } + + Future _setModel(String? value) async { + if (value == null) return; + await AppConstants.setSelectedYoloModelPath(value); + sl().dispose(); + await sl().init(); + setState(() => _selectedModel = value); + _snack('Model aktif: ${value.split('/').last}'); + } + + Future _runBenchmark() async { + setState(() => _running = true); + final started = DateTime.now(); + final captureMs = await _measureCapture(); + + final inferenceWatch = Stopwatch()..start(); + final modelLoaded = sl().isReady; + final detection = await sl().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() + .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 _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.delayed(const Duration(milliseconds: 16)); + } finally { + await controller?.dispose(); + } + watch.stop(); + return watch.elapsedMilliseconds; + } + + Future _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( + 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 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> _discoverTfliteModels() async { + try { + final manifestRaw = await rootBundle.loadString('AssetManifest.json'); + final manifest = jsonDecode(manifestRaw) as Map; + 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'); diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/data/auth_repository_impl.dart b/walkguide-mobile/walkguide_app/lib/features/auth/data/auth_repository_impl.dart index 19ffe8d..f457476 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/data/auth_repository_impl.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/data/auth_repository_impl.dart @@ -12,19 +12,34 @@ class AuthRepositoryImpl implements AuthRepository { AuthRepositoryImpl(this.remoteDataSource, this.secureStorage); @override - Future> login(String email, String password) async { + Future> login( + String email, String password) async { try { // 1. Suruh data source nembak API final userModel = await remoteDataSource.login(email, password); - + // 2. Simpan token langsung ke HP biar UI gausah ribet await secureStorage.saveToken(userModel.token); - + // 3. Balikin data sukses (Right) return Right(userModel); } catch (e) { // 4. Balikin pesan error (Left) - return Left(ServerFailure(e.toString().replaceAll('Exception: ', ''))); + return Left(ServerFailure(_safeAuthFailure(e))); } } -} \ No newline at end of file +} + +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; +} diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart index e60538e..b091280 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart @@ -2,7 +2,6 @@ import 'dart:async'; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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/injection_container.dart'; import '../../core/constants/app_constants.dart'; +import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/services/offline_queue_service.dart'; import '../../core/services/tts_service.dart'; @@ -65,27 +65,27 @@ class _LoginScreenState extends State { return; } setState(() => _loading = true); - try { - final res = await sl().dio.post('/auth/login', data: { - 'email': _email.text.trim(), - 'password': _password.text, - }); - await _saveAuthAndRoute( - context, Map.from(res.data['data'] as Map)); - } on DioException catch (e) { - _snack(context, _friendlyDioMessage(e, fallback: 'Login gagal')); - } catch (e) { - _snack(context, 'Login gagal: $e'); - } finally { - if (mounted) setState(() => _loading = false); - } + await runFriendlyAction( + () async { + final res = await sl().dio.post('/auth/login', data: { + 'email': _email.text.trim(), + 'password': _password.text, + }); + await _saveAuthAndRoute( + context, Map.from(res.data['data'] as Map)); + }, + onError: (message) => _snack(context, message), + fallback: 'Login gagal. Periksa email dan password kamu.', + connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.', + ); + if (mounted) setState(() => _loading = false); } @override Widget build(BuildContext context) { return _AuthFrame( title: 'Sign in', - subtitle: 'Masuk sebagai Guardian atau User.', + subtitle: 'Masuk ke navigasi asistif realtime WalkGuide.', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -147,42 +147,134 @@ class _AuthFrame extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460), - child: Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - side: const BorderSide(color: Color(0xFFE2E8F0))), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon(Icons.navigation_rounded, - color: Color(0xFF1A56DB), size: 42), - const SizedBox(height: 14), - Text(title, - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.w800)), - const SizedBox(height: 4), - Text(subtitle, - textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFF64748B))), - const SizedBox(height: 22), - child, - ], + 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( + 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( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 430), + child: TweenAnimationBuilder( + tween: Tween(begin: 18, end: 0), + duration: const Duration(milliseconds: 520), + curve: Curves.easeOutCubic, + 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( + padding: const EdgeInsets.fromLTRB(24, 26, 24, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + width: 56, + 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) + .textTheme + .headlineMedium + ?.copyWith( + fontWeight: FontWeight.w900, + color: const Color(0xFF0F172A), + ), + ), + const SizedBox(height: 6), + Text( + subtitle, + style: const TextStyle( + color: Color(0xFF64748B), + height: 1.35, + ), + ), + const SizedBox(height: 26), + child, + ], + ), + ), + ), + ), + ), + ), + ), + ), + ], ), ); } @@ -219,16 +311,14 @@ Future _saveAuthAndRoute( void _startPostLoginServices(String serverUrl) { Future.microtask(() async { - try { - await sl() - .connect(serverUrl) - .timeout(const Duration(seconds: 2)); - await sl() - .syncPending(sl()) - .timeout(const Duration(seconds: 3)); - } catch (e) { - debugPrint('Post-login services skipped: $e'); - } + await sl() + .connect(serverUrl) + .timeout(const Duration(seconds: 2)); + await sl() + .syncPending(sl()) + .timeout(const Duration(seconds: 3)); + }).catchError((Object e) { + debugPrint('Post-login services skipped: $e'); }); } @@ -238,19 +328,3 @@ void _snack(BuildContext context, String 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; -} diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart index d09563c..4f2b64f 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart @@ -1 +1 @@ -export '../../screens.dart'; +export '../login_screen.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart index 5720fcf..7362b7c 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart @@ -1,11 +1,11 @@ // ignore_for_file: use_build_context_synchronously -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../app/injection_container.dart'; +import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; // --------------------------------------------------------------------------- @@ -52,26 +52,26 @@ class _RegisterScreenState extends State { return; } setState(() => _loading = true); - try { - final res = await sl().dio.post('/auth/register', data: { - 'displayName': _name.text.trim(), - 'email': _email.text.trim(), - 'password': _password.text, - 'role': _role, - }); - final data = Map.from(res.data['data'] as Map); - if (!mounted) return; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('pending_login_email', _email.text.trim()); - await _showRegisterSuccess(context, data); - if (mounted) context.go('/login'); - } on DioException catch (e) { - _snack(context, _friendlyDioMessage(e, fallback: 'Registrasi gagal')); - } catch (e) { - _snack(context, 'Registrasi gagal: $e'); - } finally { - if (mounted) setState(() => _loading = false); - } + await runFriendlyAction( + () async { + final res = await sl().dio.post('/auth/register', data: { + 'displayName': _name.text.trim(), + 'email': _email.text.trim(), + 'password': _password.text, + 'role': _role, + }); + final data = Map.from(res.data['data'] as Map); + if (!mounted) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('pending_login_email', _email.text.trim()); + await _showRegisterSuccess(context, data); + if (mounted) context.go('/login'); + }, + onError: (message) => _snack(context, message), + fallback: 'Registrasi gagal. Periksa data akun kamu.', + connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.', + ); + if (mounted) setState(() => _loading = false); } @override @@ -81,7 +81,7 @@ class _RegisterScreenState extends State { subtitle: _step == 0 ? 'Who are you in the WalkGuide system?' : _role == 'USER' - ? 'User akan mendapat Unique ID untuk pairing.' + ? 'User bisa membuat Pairing Code sementara setelah login.' : 'Guardian dapat monitor dan konfigurasi User.', child: _step == 0 ? _buildRoleStep(context) : _buildFormStep(context), ); @@ -298,42 +298,134 @@ class _AuthFrame extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460), - child: Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - side: const BorderSide(color: Color(0xFFE2E8F0))), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon(Icons.navigation_rounded, - color: Color(0xFF1A56DB), size: 42), - const SizedBox(height: 14), - Text(title, - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.w800)), - const SizedBox(height: 4), - Text(subtitle, - textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFF64748B))), - const SizedBox(height: 22), - child, - ], + 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( + 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( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 430), + child: TweenAnimationBuilder( + tween: Tween(begin: 18, end: 0), + duration: const Duration(milliseconds: 520), + curve: Curves.easeOutCubic, + 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( + padding: const EdgeInsets.fromLTRB(24, 26, 24, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + width: 56, + 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) + .textTheme + .headlineMedium + ?.copyWith( + fontWeight: FontWeight.w900, + color: const Color(0xFF0F172A), + ), + ), + const SizedBox(height: 6), + Text( + subtitle, + style: const TextStyle( + color: Color(0xFF64748B), + height: 1.35, + ), + ), + const SizedBox(height: 26), + child, + ], + ), + ), + ), + ), + ), + ), + ), + ), + ], ), ); } @@ -348,12 +440,12 @@ Future _showRegisterSuccess( final uniqueId = data['uniqueUserId']?.toString(); final message = uniqueId == null || uniqueId.isEmpty ? '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( context, uniqueId == null ? 'Registrasi berhasil.' - : 'Registrasi berhasil. ID: $uniqueId'); + : 'Registrasi berhasil. Buat Pairing Code setelah login.'); await showDialog( context: context, builder: (dialogContext) => AlertDialog( @@ -375,19 +467,3 @@ void _snack(BuildContext context, String 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; -} diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart index 517cb94..482395a 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../app/injection_container.dart'; +import '../../core/errors/friendly_error.dart'; import '../../core/storage/secure_storage.dart'; // --------------------------------------------------------------------------- @@ -51,29 +52,33 @@ class _SplashScreenState extends State } Future _route() async { - try { - // Animasi logo selalu tampil minimal 500ms agar tidak langsung flash. - await Future.delayed(const Duration(milliseconds: 500)); + final routed = await runFriendlyAction( + () async { + // Animasi logo selalu tampil minimal 500ms agar tidak langsung flash. + await Future.delayed(const Duration(milliseconds: 500)); - final storage = sl(); - final token = - await storage.getAccessToken().timeout(const Duration(seconds: 3)); - final role = - await storage.getUserRole().timeout(const Duration(seconds: 3)); + final storage = sl(); + final token = + await storage.getAccessToken().timeout(const Duration(seconds: 3)); + final role = + await storage.getUserRole().timeout(const Duration(seconds: 3)); - if (!mounted) return; + if (!mounted) return; - if (token == null || role == null) { - context.go('/login'); - return; - } + if (token == null || role == null) { + context.go('/login'); + return; + } - // Auto-login: arahkan ke home sesuai role. - context.go( - role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide'); - } catch (_) { - if (mounted) context.go('/login'); - } + // Auto-login: arahkan ke home sesuai role. + context.go(role == 'ROLE_GUARDIAN' + ? '/guardian/dashboard' + : '/user/walkguide'); + }, + onError: (_) {}, + fallback: 'Sesi belum bisa dipulihkan.', + ); + if (!routed && mounted) context.go('/login'); } @override diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart index 66a9363..a30065e 100644 --- a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart @@ -7,6 +7,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import '../../../app/injection_container.dart'; +import '../../../core/errors/friendly_error.dart'; import '../../../core/network/api_client.dart'; Dio get _api => sl().dio; @@ -48,66 +49,67 @@ class _GuardianActivityLogScreenState extends State { _error = null; _needsPairing = false; }); - try { - // Cek pairing dulu - final paired = await _hasActivePairing(); - if (!paired) { + await runFriendlyAction( + () async { + // Cek pairing dulu + final paired = await _hasActivePairing(); + if (!paired) { + setState(() { + _needsPairing = true; + _loading = false; + }); + return; + } + + final res = await _api.get('/guardian/activity-logs', queryParameters: { + 'size': 50, + 'page': 0 + }).timeout(const Duration(seconds: 10)); + + // Response bisa berupa list langsung atau paged {content: [...]} + final data = res.data['data']; + List list; + if (data is List) { + list = data; + } else if (data is Map && data['content'] is List) { + list = data['content'] as List; + } else { + list = []; + } + + final items = list + .whereType() + .map((e) => _LogItem.fromJson(Map.from(e))) + .toList(); + setState(() { - _needsPairing = true; + _items = items; + _applyFilter(_selectedFilter); _loading = false; }); - return; - } - - final res = await _api.get('/guardian/activity-logs', queryParameters: { - 'size': 50, - 'page': 0 - }).timeout(const Duration(seconds: 10)); - - // Response bisa berupa list langsung atau paged {content: [...]} - final data = res.data['data']; - List list; - if (data is List) { - list = data; - } else if (data is Map && data['content'] is List) { - list = data['content'] as List; - } else { - list = []; - } - - final items = list - .whereType() - .map((e) => _LogItem.fromJson(Map.from(e))) - .toList(); - - setState(() { - _items = items; - _applyFilter(_selectedFilter); + }, + onError: (message) => setState(() { + _error = message; _loading = false; - }); - } on DioException catch (e) { - setState(() { - _error = e.response?.data?['message']?.toString() ?? - 'Gagal memuat activity log.'; - _loading = false; - }); - } catch (e) { - setState(() { - _error = 'Timeout / error: $e'; - _loading = false; - }); - } + }), + fallback: 'Gagal memuat activity log. Coba refresh lagi.', + ); } Future _hasActivePairing() async { - try { - final res = await _api - .get('/shared/pairing/status') - .timeout(const Duration(seconds: 5)); - final data = res.data['data']; - if (data is Map) return data['status'] == 'ACTIVE'; - } catch (_) {} - return false; + final active = await runFriendly( + () async { + final res = await _api + .get('/shared/pairing/status') + .timeout(const Duration(seconds: 5)); + final data = res.data['data']; + if (data is Map) return data['status'] == 'ACTIVE'; + return false; + }, + onError: (_) {}, + fallback: 'Status pairing belum bisa dicek.', + ); + return active ?? false; } void _applyFilter(String filter) { diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart index a99eb24..ce64495 100644 --- a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../app/injection_container.dart'; +import '../../../core/errors/friendly_error.dart'; import '../../../core/network/api_client.dart'; Dio get _api => sl().dio; @@ -73,11 +74,12 @@ class _GuardianAiConfigScreenState extends State { } } on DioException catch (e) { setState(() { - _error = e.response?.data?['message']?.toString() ?? - 'Gagal memuat konfigurasi AI.'; + _error = + friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.'); }); } catch (e) { - setState(() => _error = 'Timeout / error: $e'); + setState( + () => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.'); } finally { if (mounted) setState(() => _loading = false); } @@ -105,8 +107,8 @@ class _GuardianAiConfigScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(e.response?.data?['message']?.toString() ?? - 'Gagal menyimpan konfigurasi.'), + content: Text(friendlyDioMessage(e, + fallback: 'Gagal menyimpan konfigurasi.')), backgroundColor: const Color(0xFFDC2626), ), ); @@ -114,9 +116,9 @@ class _GuardianAiConfigScreenState extends State { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: const Color(0xFFDC2626), + const SnackBar( + content: Text('Gagal menyimpan konfigurasi. Coba lagi.'), + backgroundColor: Color(0xFFDC2626), ), ); } diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_map_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_map_screen.dart new file mode 100644 index 0000000..4b9ca5b --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_map_screen.dart @@ -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 createState() => _GuardianMapScreenState(); +} + +class _GuardianMapScreenState extends State { + bool _loading = true; + String? _error; + Map? _lastLocation; + List> _history = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + await runFriendlyAction( + () async { + final dio = sl().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.from(currentData) : null; + final content = historyData is Map ? historyData['content'] : null; + _history = content is List + ? content + .whereType() + .map((e) => Map.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? location; + final List> 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> 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> 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 _pointsFrom(List> history) { + return history.map(_pointFrom).whereType().toList(); +} + +LatLng? _pointFrom(Map? 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 item) { + return DateTime.tryParse(item['createdAt']?.toString() ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); +} + +String _clock(Map item) { + final time = _time(item).toLocal(); + return DateFormat('HH:mm').format(time); +} + +double _distanceBetween(Map a, Map 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> items) { + final speeds = items + .map((e) => (e['speed'] as num?)?.toDouble()) + .whereType() + .where((speed) => speed >= 0) + .toList(); + if (speeds.isEmpty) return 0; + return speeds.reduce((a, b) => a + b) / speeds.length; +} diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart index 834cabc..b0835ba 100644 --- a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart @@ -1,18 +1,15 @@ export '../home/presentation/guardian_dashboard_screen.dart' show GuardianDashboardScreen; -export 'guardian_activity_log_screen.dart' - show - GuardianActivityLogScreen; - -export 'guardian_ai_config_screen.dart' - show - GuardianAiConfigScreen; +export 'guardian_activity_log_screen.dart' show GuardianActivityLogScreen; -export '../screens.dart' - show - GuardianMapScreen, - GuardianSendNotifScreen, - GuardianVoiceCmdScreen, - GuardianShortcutScreen, - GuardianGeofenceScreen; +export 'guardian_ai_config_screen.dart' show GuardianAiConfigScreen; + +export 'guardian_map_screen.dart' show GuardianMapScreen; + +export 'guardian_send_notification_screen.dart' show GuardianSendNotifScreen; + +export 'guardian_settings_screen.dart' show GuardianSettingsScreen; + +export 'guardian_tools_screen.dart' + show GuardianVoiceCmdScreen, GuardianShortcutScreen, GuardianGeofenceScreen; diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_send_notification_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_send_notification_screen.dart new file mode 100644 index 0000000..0719855 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_send_notification_screen.dart @@ -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 createState() => + _GuardianSendNotifScreenState(); +} + +class _GuardianSendNotifScreenState extends State { + final _message = TextEditingController(); + bool _loading = false; + + @override + void dispose() { + _message.dispose(); + super.dispose(); + } + + Future _send() async { + final message = _message.text.trim(); + if (message.isEmpty) { + _snack('Tulis pesan dulu.'); + return; + } + setState(() => _loading = true); + await runFriendlyAction( + () async { + await sl().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'), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_settings_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_settings_screen.dart new file mode 100644 index 0000000..c9b38fd --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_settings_screen.dart @@ -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 _logout(BuildContext context) async { + final appCubit = context.read(); + await sl().clearAll(); + appCubit.clearSession(); + unawaited(_notifyBackendLogout()); + if (context.mounted) context.go('/login'); + } + + Future _notifyBackendLogout() async { + await runFriendlyAction( + () async { + await sl() + .dio + .post('/auth/logout') + .timeout(const Duration(seconds: 3)); + }, + onError: (_) {}, + fallback: 'Logout server belum bisa dikonfirmasi.', + ); + } + + Future _changeServer(BuildContext context) async { + await AppConstants.clearServerUrl(); + await sl().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, + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_tools_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_tools_screen.dart new file mode 100644 index 0000000..9734dd1 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_tools_screen.dart @@ -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> _items = const []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + await runFriendlyAction( + () async { + final res = await sl().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> _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((item) => Map.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 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 item, List keys) { + for (final key in keys) { + final value = item[key]?.toString().trim(); + if (value != null && value.isNotEmpty && value != 'null') return value; + } + return null; +} diff --git a/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart b/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart index 05dc4c6..65414eb 100644 --- a/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart @@ -9,6 +9,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; @@ -38,12 +39,14 @@ class _Step { required this.point}); } -// ─── BLoC-lite state (plain ChangeNotifier to avoid heavy BLoC boilerplate -// while staying consistent with the rest of screens.dart approach) ───── +// Navigation state is kept in a lightweight Cubit so the app uses one +// state-management family consistently. enum _NavPhase { idle, locating, routing, navigating, error } -class _NavState extends ChangeNotifier { +class _NavState extends Cubit { + _NavState() : super(0); + _NavPhase phase = _NavPhase.idle; String statusText = 'Ketuk tombol lokasi atau cari tujuan.'; LatLng? currentPosition; @@ -59,9 +62,11 @@ class _NavState extends ChangeNotifier { void _set(_NavPhase p, String status) { phase = p; statusText = status; - notifyListeners(); + _notify(); } + void _notify() => emit(state + 1); + // ── locate ────────────────────────────────────────────────────────────── Future locate() async { _set(_NavPhase.locating, 'Mencari lokasi GPS…'); @@ -85,7 +90,8 @@ class _NavState extends ChangeNotifier { _set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.'); return false; } catch (e) { - _set(_NavPhase.error, 'GPS error: $e'); + _set(_NavPhase.error, + 'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.'); return false; } } @@ -211,10 +217,11 @@ class _NavState extends ChangeNotifier { _set(_NavPhase.navigating, steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.'); - notifyListeners(); + _notify(); _startTracking(); } 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); _reportToBackend(pos); _updateStep(); - notifyListeners(); + _notify(); }); } @@ -302,7 +309,7 @@ class _NavState extends ChangeNotifier { final next = steps[currentStepIndex]; statusText = next.instruction; sl().speak(next.instruction); - notifyListeners(); + _notify(); } } } @@ -318,9 +325,9 @@ class _NavState extends ChangeNotifier { } @override - void dispose() { + Future close() { _posStream?.cancel(); - super.dispose(); + return super.close(); } } @@ -339,6 +346,7 @@ class _NavigationModeScreenState extends State { final _searchCtrl = TextEditingController(); final _searchFocus = FocusNode(); + StreamSubscription? _navSubscription; List<_Place> _suggestions = const []; bool _searchLoading = false; bool _showSuggestions = false; @@ -348,7 +356,7 @@ class _NavigationModeScreenState extends State { @override void initState() { super.initState(); - _navState.addListener(_onStateChange); + _navSubscription = _navState.stream.listen((_) => _onStateChange()); WidgetsBinding.instance.addPostFrameCallback((_) => _init()); } @@ -433,8 +441,8 @@ class _NavigationModeScreenState extends State { @override void dispose() { _debounce?.cancel(); - _navState.removeListener(_onStateChange); - _navState.dispose(); + _navSubscription?.cancel(); + _navState.close(); _searchCtrl.dispose(); _searchFocus.dispose(); super.dispose(); diff --git a/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart b/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart index 771b2cb..23dd93e 100644 --- a/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart @@ -6,6 +6,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../app/injection_container.dart'; +import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/services/tts_service.dart'; import '../../core/theme/app_colors.dart'; @@ -36,24 +37,20 @@ class _NotificationScreenState extends State { _loading = true; _error = null; }); - try { - final res = await _api - .get('/user/notifications') - .timeout(const Duration(seconds: 10)); - final list = _extractList(res.data); - setState(() { - _items = list.map((e) => _NotifItem.fromJson(e)).toList(); - }); - } on DioException catch (e) { - setState(() { - _error = e.response?.data?['message']?.toString() ?? - 'Gagal memuat notifikasi.'; - }); - } catch (e) { - setState(() => _error = 'Timeout / error: $e'); - } finally { - if (mounted) setState(() => _loading = false); - } + await runFriendlyAction( + () async { + final res = await _api + .get('/user/notifications') + .timeout(const Duration(seconds: 10)); + final list = _extractList(res.data); + setState(() { + _items = list.map((e) => _NotifItem.fromJson(e)).toList(); + }); + }, + onError: (message) => setState(() => _error = message), + fallback: 'Gagal memuat notifikasi. Coba refresh lagi.', + ); + if (mounted) setState(() => _loading = false); } List> _extractList(dynamic responseBody) { @@ -67,35 +64,37 @@ class _NotificationScreenState extends State { } Future _markRead(int id) async { - try { - await _api - .put('/user/notifications/$id/read') - .timeout(const Duration(seconds: 6)); - setState(() { - final idx = _items.indexWhere((n) => n.id == id); - if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true); - }); - } catch (_) {} + await runFriendlyAction( + () async { + await _api + .put('/user/notifications/$id/read') + .timeout(const Duration(seconds: 6)); + setState(() { + final idx = _items.indexWhere((n) => n.id == id); + if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true); + }); + }, + onError: (_) {}, + fallback: 'Gagal menandai notifikasi.', + ); } Future _markAllRead() async { setState(() => _markingAll = true); - try { - await _api - .put('/user/notifications/mark-all-read') - .timeout(const Duration(seconds: 8)); - setState(() { - _items = _items.map((n) => n.copyWith(isRead: true)).toList(); - }); - _snack('Semua notifikasi ditandai sudah dibaca.'); - } on DioException catch (e) { - _snack(e.response?.data?['message']?.toString() ?? - 'Gagal menandai semua dibaca.'); - } catch (_) { - _snack('Timeout. Coba lagi.'); - } finally { - if (mounted) setState(() => _markingAll = false); - } + await runFriendlyAction( + () async { + await _api + .put('/user/notifications/mark-all-read') + .timeout(const Duration(seconds: 8)); + setState(() { + _items = _items.map((n) => n.copyWith(isRead: true)).toList(); + }); + _snack('Semua notifikasi ditandai sudah dibaca.'); + }, + onError: _snack, + fallback: 'Gagal menandai semua dibaca.', + ); + if (mounted) setState(() => _markingAll = false); } Future _readAloud(_NotifItem notif) async { diff --git a/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart b/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart index c70745d..ed14527 100644 --- a/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart +++ b/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart @@ -2,10 +2,10 @@ import 'dart:async'; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import '../../app/injection_container.dart'; +import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/storage/secure_storage.dart'; @@ -14,7 +14,7 @@ import '../../core/storage/secure_storage.dart'; // --------------------------------------------------------------------------- // // 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 sudah paired → tampilkan info Guardian + tombol Unpair. // --------------------------------------------------------------------------- @@ -28,6 +28,10 @@ class UserPairingScreen extends StatefulWidget { class _UserPairingScreenState extends State { String? _uniqueId; + String? _pairingCode; + DateTime? _pairingCodeExpiresAt; + int? _pairingCodeSeconds; + bool _regenerating = false; @override void initState() { @@ -38,36 +42,87 @@ class _UserPairingScreenState extends State { Future _loadUniqueId() async { var value = await sl().getUniqueUserId(); if (value == null || value.isEmpty) { - try { - final res = await sl() - .dio - .get('/user/profile') - .timeout(const Duration(seconds: 5)); - final data = res.data['data']; - if (data is Map) value = data['uniqueUserId']?.toString(); - } catch (_) {} + await runFriendlyAction( + () async { + final res = await sl() + .dio + .get('/user/profile') + .timeout(const Duration(seconds: 5)); + final data = res.data['data']; + if (data is Map) value = data['uniqueUserId']?.toString(); + }, + onError: (_) {}, + fallback: 'Profil belum bisa dimuat.', + ); } if (mounted) setState(() => _uniqueId = value); } + Future _regeneratePairingCode() async { + setState(() => _regenerating = true); + await runFriendlyAction( + () async { + final res = await sl() + .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 Widget build(BuildContext context) { return _Page( title: 'Pairing', - subtitle: 'Bagikan Unique ID ini ke Guardian untuk terhubung.', + subtitle: 'Bagikan pairing code sementara ini ke Guardian.', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (_uniqueId == null || _uniqueId!.isEmpty) + if (_pairingCode == null || _pairingCode!.isEmpty) _InfoCard( - title: 'Your Unique ID', - value: 'Login sebagai User untuk melihat ID', - icon: Icons.qr_code_2) + title: 'Pairing Code', + value: 'Tap Generate', + icon: Icons.qr_code_2, + helper: + 'Kode dibuat saat dibutuhkan, berlaku sementara, dan bisa dibuat ulang kapan saja.') else _InfoCard( - title: 'Your Unique ID', - value: _uniqueId!, - icon: Icons.qr_code_2), + title: 'Pairing Code', + value: _pairingCode!, + 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), _PairingStatusCard(allowUserResponse: true), ], @@ -81,7 +136,7 @@ class _UserPairingScreenState extends State { // --------------------------------------------------------------------------- // // Ditampilkan ke akun ROLE_GUARDIAN. -// - Input field 12-char User ID. +// - Input field 8-char temporary pairing code. // - Tombol "Send Invite". // - Status card: jika sudah paired → info User + tombol Unpair. // Jika pending → waiting state. @@ -100,51 +155,46 @@ class _GuardianPairingScreenState extends State { int _statusReload = 0; Future _invite() async { - final uniqueId = _id.text.trim().toUpperCase(); - if (uniqueId.isEmpty || uniqueId.length != 12) { - _snack(context, 'Unique ID harus 12 karakter dari akun User.'); + final pairingCode = _id.text.trim().toUpperCase(); + if (pairingCode.isEmpty || pairingCode.length != 8) { + _snack(context, 'Pairing code harus 8 karakter dari akun User.'); return; } setState(() => _loading = true); - try { - final res = await sl().dio.post('/shared/pairing/invite', - data: {'uniqueUserId': uniqueId}).timeout(const Duration(seconds: 8)); - _snack( - context, - res.data['message']?.toString() ?? - 'Invite terkirim. Minta User buka menu Pairing lalu Accept.'); - setState(() => _statusReload++); - } on DioException catch (e) { - _snack( - context, - _friendlyDioMessage(e, - fallback: - 'Invite gagal. Pastikan kamu login sebagai Guardian dan ID User 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); - } + await runFriendlyAction( + () async { + final res = await sl().dio.post('/shared/pairing/invite', + data: { + 'pairingCode': pairingCode + }).timeout(const Duration(seconds: 8)); + _snack( + context, + res.data['message']?.toString() ?? + 'Invite terkirim. Minta User buka menu Pairing lalu Accept.'); + setState(() => _statusReload++); + }, + onError: (message) => _snack(context, message), + fallback: + 'Invite gagal. Pastikan kamu login sebagai Guardian dan pairing code benar.', + ); + if (mounted) setState(() => _loading = false); } @override Widget build(BuildContext context) { return _Page( title: 'Pair User', - subtitle: 'Masukkan 12 karakter Unique ID milik User.', + subtitle: 'Masukkan 8 karakter pairing code aktif dari User.', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: _id, textCapitalization: TextCapitalization.characters, - maxLength: 12, + maxLength: 8, decoration: const InputDecoration( - labelText: 'Unique User ID', - hintText: 'Contoh: AB1C2D3E4F5G', + labelText: 'Pairing Code', + hintText: 'Contoh: A7K9Q2M4', prefixIcon: Icon(Icons.link), )), FilledButton.icon( @@ -192,50 +242,42 @@ class _PairingStatusCardState extends State<_PairingStatusCard> { Future _load() async { setState(() => _loading = true); - try { - final token = await sl().getAccessToken(); - if (token == null || token.isEmpty) { + await runFriendlyAction( + () async { + final token = await sl().getAccessToken(); + if (token == null || token.isEmpty) { + _active = false; + _data = null; + _status = 'Belum login. Login dulu supaya status pairing bisa dicek.'; + return; + } + final res = await sl() + .dio + .get('/shared/pairing/status') + .timeout(const Duration(seconds: 5)); + final data = res.data['data']; + _data = data is Map ? Map.from(data) : null; + _active = data is Map && data['status'] == 'ACTIVE'; + if (data is Map && data['status'] == 'ACTIVE') { + _active = true; + _status = + 'Sudah pairing dengan ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'akun lain'}.'; + } else if (data is Map && data['status'] == 'PENDING') { + _status = widget.allowUserResponse + ? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.' + : 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.'; + } else { + _status = 'Belum pairing. Bagikan pairing code aktif ke Guardian.'; + } + }, + onError: (message) { _active = false; _data = null; - _status = 'Belum login. Login dulu supaya status pairing bisa dicek.'; - return; - } - final res = await sl() - .dio - .get('/shared/pairing/status') - .timeout(const Duration(seconds: 5)); - final data = res.data['data']; - _data = data is Map ? Map.from(data) : null; - _active = data is Map && data['status'] == 'ACTIVE'; - if (data is Map && data['status'] == 'ACTIVE') { - _active = true; - _status = - 'Sudah pairing dengan ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'akun lain'}.'; - } else if (data is Map && data['status'] == 'PENDING') { - _status = widget.allowUserResponse - ? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.' - : 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.'; - } else { - _status = 'Belum pairing. Bagikan Unique ID kamu ke Guardian.'; - } - } on DioException catch (e) { - _active = false; - _data = null; - _status = _friendlyDioMessage(e, - fallback: - 'Belum bisa mengecek server, tapi Unique ID tetap bisa dibagikan.'); - } 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); - } + _status = message; + }, + fallback: 'Status pairing belum bisa dicek. Coba refresh lagi.', + ); + if (mounted) setState(() => _loading = false); } Future _respond(bool accept) async { @@ -245,25 +287,23 @@ class _PairingStatusCardState extends State<_PairingStatusCard> { return; } setState(() => _responding = true); - try { - final res = - await sl().dio.post('/shared/pairing/respond', data: { - 'pairingId': pairingId, - 'accept': accept, - }).timeout(const Duration(seconds: 8)); - _snack( - context, - res.data['message']?.toString() ?? - (accept ? 'Pairing diterima.' : 'Pairing ditolak.')); - await _load(); - } on DioException catch (e) { - _snack(context, - _friendlyDioMessage(e, fallback: 'Gagal merespons pairing.')); - } on TimeoutException { - _snack(context, 'Server terlalu lama merespons pairing.'); - } finally { - if (mounted) setState(() => _responding = false); - } + await runFriendlyAction( + () async { + final res = + await sl().dio.post('/shared/pairing/respond', data: { + 'pairingId': pairingId, + 'accept': accept, + }).timeout(const Duration(seconds: 8)); + _snack( + context, + res.data['message']?.toString() ?? + (accept ? 'Pairing diterima.' : 'Pairing ditolak.')); + await _load(); + }, + onError: (message) => _snack(context, message), + fallback: 'Gagal merespons pairing.', + ); + if (mounted) setState(() => _responding = false); } Future _unpair() async { @@ -287,19 +327,19 @@ class _PairingStatusCardState extends State<_PairingStatusCard> { ); if (confirmed != true) return; setState(() => _responding = true); - try { - await sl() - .dio - .delete('/shared/pairing/unpair') - .timeout(const Duration(seconds: 8)); - _snack(context, 'Pairing telah diputus.'); - await _load(); - } on DioException catch (e) { - _snack( - context, _friendlyDioMessage(e, fallback: 'Gagal memutus pairing.')); - } finally { - if (mounted) setState(() => _responding = false); - } + await runFriendlyAction( + () async { + await sl() + .dio + .delete('/shared/pairing/unpair') + .timeout(const Duration(seconds: 8)); + _snack(context, 'Pairing telah diputus.'); + await _load(); + }, + onError: (message) => _snack(context, message), + fallback: 'Gagal memutus pairing.', + ); + if (mounted) setState(() => _responding = false); } @override @@ -424,8 +464,12 @@ class _InfoCard extends StatelessWidget { final String title; final String value; final IconData icon; + final String? helper; const _InfoCard( - {required this.title, required this.value, required this.icon}); + {required this.title, + required this.value, + required this.icon, + this.helper}); @override Widget build(BuildContext context) { @@ -445,7 +489,13 @@ class _InfoCard extends StatelessWidget { Text(title), SelectableText(value, 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}) { - final data = e.response?.data; - if (data is Map && data['message'] != null) return data['message'].toString(); - if (e.response?.statusCode == 401) { - return 'Sesi login habis. Logout lalu login ulang.'; - } - if (e.response?.statusCode == 403) { - 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; +String _formatRemaining(int? seconds, DateTime? expiresAt) { + final value = seconds ?? expiresAt?.difference(DateTime.now()).inSeconds; + if (value == null || value <= 0) return 'sudah kadaluarsa'; + final minutes = value ~/ 60; + final secs = value % 60; + if (minutes <= 0) return '$secs detik'; + return '$minutes menit ${secs.toString().padLeft(2, '0')} detik'; } diff --git a/walkguide-mobile/walkguide_app/lib/features/screens.dart b/walkguide-mobile/walkguide_app/lib/features/screens.dart deleted file mode 100644 index 0d7b575..0000000 --- a/walkguide-mobile/walkguide_app/lib/features/screens.dart +++ /dev/null @@ -1,2412 +0,0 @@ -// ignore_for_file: use_build_context_synchronously, deprecated_member_use - -import 'dart:async'; -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:camera/camera.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter/services.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:go_router/go_router.dart'; -import 'package:latlong2/latlong.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../app/injection_container.dart'; -import '../app/app_cubit.dart'; -import '../core/ai/detection_export.dart'; -import '../core/constants/app_constants.dart'; -import '../core/network/api_client.dart'; -import '../core/services/call_service.dart'; -import '../core/services/haptic_service.dart'; -import '../core/services/location_reporter_service.dart'; -import '../core/services/offline_queue_service.dart'; -import '../core/services/tts_service.dart'; -import '../core/services/websocket_service.dart'; -import '../core/storage/secure_storage.dart'; - -export 'guardian_dashboard/guardian_screens.dart'; - -Dio get _api => sl().dio; - -class ServerConnectScreen extends StatefulWidget { - const ServerConnectScreen({super.key}); - - @override - State createState() => _ServerConnectScreenState(); -} - -class _ServerConnectScreenState extends State { - final _url = TextEditingController(text: 'http://202.46.28.160:8080'); - bool _loading = false; - bool _ok = false; - String? _message; - - Future _test() async { - setState(() { - _loading = true; - _ok = false; - _message = null; - }); - try { - final clean = AppConstants.normalizeServerUrl(_url.text); - final res = await Dio(BaseOptions( - connectTimeout: AppConstants.pingTimeout, - receiveTimeout: AppConstants.pingTimeout, - )).get('$clean/api/v1/auth/ping'); - _ok = res.statusCode == 200 && res.data['success'] == true; - _message = _ok - ? 'Server aktif dan siap dipakai.' - : 'Server merespons dengan format tidak valid.'; - } catch (e) { - _message = 'Tidak bisa terhubung. Periksa URL dan jaringan.'; - } finally { - if (mounted) setState(() => _loading = false); - } - } - - Future _continue() async { - final clean = AppConstants.normalizeServerUrl(_url.text); - await AppConstants.setServerUrl(clean); - await sl().init(clean); - if (mounted) context.go('/splash'); - } - - @override - Widget build(BuildContext context) { - return _AuthFrame( - title: 'Connect to Server', - subtitle: 'Masukkan URL backend WalkGuide yang diberikan dosen.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - controller: _url, - keyboardType: TextInputType.url, - decoration: const InputDecoration(labelText: 'Server URL')), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _loading ? null : _test, - icon: _loading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.wifi_tethering), - label: const Text('Test Connection'), - ), - if (_message != null) ...[ - const SizedBox(height: 12), - _StatusBox(success: _ok, message: _message!), - ], - if (_ok) ...[ - const SizedBox(height: 12), - FilledButton.icon( - onPressed: _continue, - icon: const Icon(Icons.arrow_forward), - label: const Text('Continue')), - ], - ], - ), - ); - } -} - -class SplashScreen extends StatefulWidget { - const SplashScreen({super.key}); - - @override - State createState() => _SplashScreenState(); -} - -class _SplashScreenState extends State { - @override - void initState() { - super.initState(); - _route(); - } - - Future _route() async { - try { - await Future.delayed(const Duration(milliseconds: 500)); - final storage = sl(); - final token = - await storage.getAccessToken().timeout(const Duration(seconds: 3)); - final role = - await storage.getUserRole().timeout(const Duration(seconds: 3)); - if (!mounted) return; - if (token == null || role == null) { - context.go('/login'); - } else { - context.go(role == 'ROLE_GUARDIAN' - ? '/guardian/dashboard' - : '/user/walkguide'); - } - } catch (_) { - if (mounted) context.go('/login'); - } - } - - @override - Widget build(BuildContext context) { - return const Scaffold( - backgroundColor: Color(0xFF1A56DB), - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.navigation_rounded, color: Colors.white, size: 72), - SizedBox(height: 18), - Text('WalkGuide', - style: TextStyle( - color: Colors.white, - fontSize: 34, - fontWeight: FontWeight.w800)), - SizedBox(height: 32), - CircularProgressIndicator(color: Colors.white), - ], - ), - ), - ); - } -} - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - final _email = TextEditingController(); - final _password = TextEditingController(); - bool _loading = false; - - @override - void initState() { - super.initState(); - _loadPendingLoginEmail(); - } - - Future _loadPendingLoginEmail() async { - final prefs = await SharedPreferences.getInstance(); - final pendingEmail = prefs.getString('pending_login_email'); - if (!mounted) return; - setState(() { - if (pendingEmail != null && pendingEmail.isNotEmpty) { - _email.text = pendingEmail; - } - }); - await prefs.remove('pending_login_email'); - } - - Future _login() async { - if (_email.text.trim().isEmpty || _password.text.isEmpty) { - _snack(context, 'Isi email dan password dulu.'); - return; - } - setState(() => _loading = true); - try { - final res = await _api.post('/auth/login', data: { - 'email': _email.text.trim(), - 'password': _password.text, - }); - await _saveAuthAndRoute( - context, Map.from(res.data['data'] as Map)); - } on DioException catch (e) { - _snack(context, e.response?.data['message'] ?? 'Login gagal'); - } catch (e) { - _snack(context, 'Login gagal: $e'); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - return _AuthFrame( - title: 'Sign in', - subtitle: 'Masuk sebagai Guardian atau User.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - controller: _email, - decoration: const InputDecoration(labelText: 'Email')), - const SizedBox(height: 12), - TextField( - controller: _password, - obscureText: true, - decoration: const InputDecoration(labelText: 'Password')), - const SizedBox(height: 18), - FilledButton.icon( - onPressed: _loading ? null : _login, - icon: _loading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.login), - label: const Text('Login'), - ), - TextButton( - onPressed: () => context.go('/register'), - child: const Text('Buat akun baru')), - ], - ), - ); - } -} - -class RegisterScreen extends StatefulWidget { - const RegisterScreen({super.key}); - - @override - State createState() => _RegisterScreenState(); -} - -class _RegisterScreenState extends State { - final _name = TextEditingController(); - final _email = TextEditingController(); - final _password = TextEditingController(); - String _role = 'USER'; - bool _loading = false; - - Future _register() async { - setState(() => _loading = true); - try { - final res = await _api.post('/auth/register', data: { - 'displayName': _name.text.trim(), - 'email': _email.text.trim(), - 'password': _password.text, - 'role': _role, - }); - final data = Map.from(res.data['data'] as Map); - if (!mounted) return; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('pending_login_email', _email.text.trim()); - await _showRegisterSuccess(context, data); - if (mounted) context.go('/login'); - } on DioException catch (e) { - _snack(context, e.response?.data['message'] ?? 'Registrasi gagal'); - } catch (e) { - _snack(context, 'Registrasi gagal: $e'); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - return _AuthFrame( - title: 'Create Account', - subtitle: 'User akan mendapat Unique ID untuk pairing.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SegmentedButton( - segments: const [ - ButtonSegment(value: 'USER', label: Text('User')), - ButtonSegment(value: 'GUARDIAN', label: Text('Guardian')), - ], - selected: {_role}, - onSelectionChanged: (value) => setState(() => _role = value.first), - ), - const SizedBox(height: 16), - TextField( - controller: _name, - decoration: const InputDecoration(labelText: 'Display name')), - const SizedBox(height: 12), - TextField( - controller: _email, - decoration: const InputDecoration(labelText: 'Email')), - const SizedBox(height: 12), - TextField( - controller: _password, - obscureText: true, - decoration: const InputDecoration(labelText: 'Password')), - const SizedBox(height: 18), - FilledButton.icon( - onPressed: _loading ? null : _register, - icon: const Icon(Icons.person_add_alt_1), - label: const Text('Register'), - ), - TextButton( - onPressed: () => context.go('/login'), - child: const Text('Sudah punya akun')), - ], - ), - ); - } -} - -class WalkGuideScreen extends StatefulWidget { - const WalkGuideScreen({super.key}); - - @override - State createState() => _WalkGuideScreenState(); -} - -class _WalkGuideScreenState extends State { - bool _active = false; - String _status = 'Ready'; - CameraController? _camera; - DetectionResult? _lastDetection; - bool _processingFrame = false; - DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0); - DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0); - - @override - void dispose() { - final camera = _camera; - if (camera != null && camera.value.isStreamingImages) { - unawaited(camera.stopImageStream().catchError((Object _) {})); - } - _camera?.dispose(); - sl().stop(); - super.dispose(); - } - - Future _toggle() async { - final next = !_active; - if (next) { - await _startCamera(); - await sl().start(walkGuideActive: true); - } else { - await _stopCamera(); - await sl().stop(); - } - setState(() { - _active = next; - _status = next ? 'Camera stream active. YOLO ready.' : 'Stopped'; - }); - await _api.post(next ? '/user/walkguide/start' : '/user/walkguide/stop'); - sl().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan'); - } - - Future _startCamera() async { - if (_camera != null) return; - try { - final cameras = await availableCameras(); - if (cameras.isEmpty) return; - final controller = CameraController( - cameras.first, - ResolutionPreset.medium, - enableAudio: false, - imageFormatGroup: ImageFormatGroup.yuv420, - ); - await controller.initialize(); - if (!mounted) { - await controller.dispose(); - return; - } - try { - await controller.startImageStream(_onCameraImage); - } catch (_) { - // Preview still works; manual Demo Detect remains available. - } - setState(() => _camera = controller); - } catch (_) { - setState(() => _status = 'Camera unavailable. Demo mode active.'); - } - } - - Future _stopCamera() async { - final camera = _camera; - _camera = null; - if (camera == null) return; - try { - if (camera.value.isStreamingImages) { - await camera.stopImageStream(); - } - } catch (_) {} - await camera.dispose(); - } - - void _onCameraImage(CameraImage image) { - if (!_active || _processingFrame) return; - final now = DateTime.now(); - if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) { - return; - } - _lastInferenceAt = now; - _processingFrame = true; - unawaited(_runYolo(image).whenComplete(() => _processingFrame = false)); - } - - Future _runYolo(CameraImage image) async { - final detection = await sl().detect(image); - if (detection == null || !mounted) return; - await _handleDetection(detection); - } - - Future _simulateObstacle() async { - final detection = await sl().detectFallback(); - if (detection == null) return; - await _handleDetection(detection, forceAlert: true); - } - - Future _handleDetection( - DetectionResult detection, { - bool forceAlert = false, - }) async { - _lastDetection = detection; - setState(() => _status = - 'Obstacle: ${detection.label} ${detection.directionName} ${detection.estimatedDistance}'); - - final now = DateTime.now(); - if (!forceAlert && - now.difference(_lastAlertAt) < const Duration(seconds: 4)) { - return; - } - _lastAlertAt = now; - - try { - await _api.post('/user/obstacle', data: { - 'label': detection.label, - 'confidence': detection.confidence, - 'direction': detection.directionName, - 'estimatedDist': detection.estimatedDistance, - 'lat': null, - 'lng': null, - }); - } catch (_) {} - await sl().obstacleClose(); - await sl().speakImmediate(detection.spokenId); - } - - @override - Widget build(BuildContext context) { - return _Page( - title: 'WalkGuide', - subtitle: 'On-device AI detection surface', - actions: [ - IconButton( - onPressed: () => context.go('/user/benchmark'), - icon: const Icon(Icons.speed)), - IconButton( - onPressed: () => context.go('/user/pairing'), - icon: const Icon(Icons.link)), - ], - child: Column( - children: [ - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(16)), - child: Stack( - children: [ - if (_camera != null && _camera!.value.isInitialized) - Positioned.fill(child: CameraPreview(_camera!)) - else - const Center( - child: Icon(Icons.videocam_outlined, - color: Colors.white30, size: 96)), - Positioned( - top: 16, - left: 16, - child: _Pill( - text: _active ? 'AI ACTIVE' : 'STANDBY', - color: _active ? Colors.green : Colors.orange)), - if (_lastDetection != null) - Positioned( - top: 64, - left: 16, - child: _Pill( - text: - '${_lastDetection!.label} ${_lastDetection!.directionName}', - color: Colors.redAccent), - ), - Positioned( - left: 16, - right: 16, - bottom: 16, - child: Text(_status, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w700))), - ], - ), - ), - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: _toggle, - icon: Icon(_active ? Icons.stop : Icons.play_arrow), - 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'))), - ], - ), - ], - ), - ); - } -} - -class SosScreen extends StatelessWidget { - const SosScreen({super.key}); - - Future _sendSos() async { - Position? pos; - try { - await Geolocator.requestPermission(); - pos = await Geolocator.getCurrentPosition(); - } catch (_) {} - await _api.post('/user/sos', data: { - 'triggerType': 'MANUAL', - 'lat': pos?.latitude, - 'lng': pos?.longitude, - }); - await sl().sosTriggered(); - sl().speak('SOS terkirim ke Guardian.'); - } - - @override - Widget build(BuildContext context) { - return _Page( - title: 'SOS', - subtitle: 'Emergency alert with location', - child: Center( - child: SizedBox.square( - dimension: 220, - child: FilledButton( - style: FilledButton.styleFrom( - shape: const CircleBorder(), - backgroundColor: const Color(0xFFDC2626)), - onPressed: _sendSos, - child: const Text('SOS', - style: TextStyle(fontSize: 42, fontWeight: FontWeight.w900)), - ), - ), - ), - ); - } -} - -class ActivityLogScreen extends StatelessWidget { - const ActivityLogScreen({super.key}); - @override - Widget build(BuildContext context) => const _EndpointListScreen( - title: 'Activity Logs', endpoint: '/user/activity-logs'); -} - -class NotificationScreen extends StatelessWidget { - const NotificationScreen({super.key}); - @override - Widget build(BuildContext context) => const _EndpointListScreen( - title: 'Notifications', endpoint: '/user/notifications'); -} - -class NavigationModeScreen extends StatelessWidget { - const NavigationModeScreen({super.key}); - @override - Widget build(BuildContext context) => const _MapScreen( - title: 'Navigation', - subtitle: 'Current position and OSM map', - ); -} - -class UserSettingsScreen extends StatefulWidget { - const UserSettingsScreen({super.key}); - - @override - State createState() => _UserSettingsScreenState(); -} - -class _UserSettingsScreenState extends State { - bool _haptic = true; - String _language = 'id-ID'; - - Future _save() async { - await sl().setLanguage(_language); - _snack(context, 'Settings tersimpan di perangkat.'); - try { - await _api.put('/user/settings', data: { - 'ttsLanguage': _language, - 'hapticEnabled': _haptic, - 'ttsPitch': 1.0, - 'ttsSpeed': 0.9, - 'warnNoGuardian': true, - }).timeout(const Duration(seconds: 8)); - } catch (e) { - _snack(context, - 'Server belum menerima settings, tapi pilihan lokal sudah dipakai.'); - } - } - - Future _logout() async { - await sl().clearAll(); - context.read().clearSession(); - unawaited(_ignoreFailure( - _api.post('/auth/logout').timeout(const Duration(seconds: 3)))); - if (mounted) context.go('/login'); - } - - @override - Widget build(BuildContext context) { - return _Page( - title: 'Settings', - subtitle: 'TTS, haptic, account, and server', - child: ListView( - children: [ - DropdownButtonFormField( - value: _language, - decoration: const InputDecoration(labelText: 'TTS language'), - items: const [ - DropdownMenuItem(value: 'id-ID', child: Text('Bahasa Indonesia')), - DropdownMenuItem(value: 'en-US', child: Text('English')), - ], - onChanged: (value) => - setState(() => _language = value ?? _language), - ), - const SizedBox(height: 12), - SwitchListTile( - value: _haptic, - onChanged: (value) => setState(() => _haptic = value), - title: const Text('Haptic obstacle alert'), - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: _save, - icon: const Icon(Icons.save), - label: const Text('Save settings')), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: () async { - await AppConstants.clearServerUrl(); - if (context.mounted) context.go('/server-connect'); - }, - icon: const Icon(Icons.dns_outlined), - label: const Text('Change server'), - ), - OutlinedButton.icon( - onPressed: _logout, - icon: const Icon(Icons.logout), - label: const Text('Logout')), - ], - ), - ); - } -} - -class GuardianSettingsScreen extends StatelessWidget { - const GuardianSettingsScreen({super.key}); - - Future _logout(BuildContext context) async { - await sl().clearAll(); - context.read().clearSession(); - unawaited(_ignoreFailure( - _api.post('/auth/logout').timeout(const Duration(seconds: 3)))); - if (context.mounted) context.go('/login'); - } - - @override - Widget build(BuildContext context) { - return _Page( - title: 'Guardian Settings', - subtitle: 'Account, server, pairing, and tools', - child: ListView( - children: [ - ListTile( - leading: const Icon(Icons.link), - title: const Text('Pair User'), - subtitle: - const Text('Masukkan Unique ID User atau cek status pairing.'), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.go('/guardian/pairing'), - ), - ListTile( - leading: const Icon(Icons.speed), - title: const Text('AI Benchmark'), - subtitle: const Text( - 'Catat waktu capture, model, notification text, dan TTS.'), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.go('/guardian/benchmark'), - ), - ListTile( - leading: const Icon(Icons.tune), - title: const Text('AI Config'), - subtitle: const Text( - 'Buka konfigurasi AI untuk User yang sudah pairing.'), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.go('/guardian/ai-config'), - ), - const Divider(height: 28), - OutlinedButton.icon( - onPressed: () async { - await AppConstants.clearServerUrl(); - await sl().clearAll(); - if (context.mounted) context.go('/server-connect'); - }, - 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 UserPairingScreen extends StatefulWidget { - const UserPairingScreen({super.key}); - - @override - State createState() => _UserPairingScreenState(); -} - -class _UserPairingScreenState extends State { - String? _uniqueId; - - @override - void initState() { - super.initState(); - _loadUniqueId(); - } - - Future _loadUniqueId() async { - var value = await sl().getUniqueUserId(); - if (value == null || value.isEmpty) { - try { - final res = - await _api.get('/user/profile').timeout(const Duration(seconds: 5)); - final data = res.data['data']; - if (data is Map) value = data['uniqueUserId']?.toString(); - } catch (_) {} - } - if (mounted) setState(() => _uniqueId = value); - } - - @override - Widget build(BuildContext context) { - return _Page( - title: 'Pairing', - subtitle: 'Bagikan Unique ID ini ke Guardian untuk terhubung.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (_uniqueId == null || _uniqueId!.isEmpty) - const _InfoCard( - title: 'Your Unique ID', - value: 'Login sebagai User untuk melihat ID', - icon: Icons.qr_code_2) - else - _InfoCard( - title: 'Your Unique ID', - value: _uniqueId!, - icon: Icons.qr_code_2), - const SizedBox(height: 16), - const _PairingStatusCard(allowUserResponse: true), - ], - ), - ); - } -} - -class CallScreen extends StatelessWidget { - const CallScreen({super.key}); - @override - Widget build(BuildContext context) => - const _CallPanel(title: 'Call Guardian', channelName: 'walkguide-call'); -} - -class IncomingCallScreen extends StatelessWidget { - const IncomingCallScreen({super.key}); - @override - Widget build(BuildContext context) => const _PlaceholderScreen( - title: 'Incoming Call', - icon: Icons.call_received, - text: 'Accept or reject incoming guardian calls here.'); -} - -class GuardianMapScreen extends StatelessWidget { - const GuardianMapScreen({super.key}); - @override - Widget build(BuildContext context) => const _GuardianMapHistoryScreen(); -} - -class GuardianSendNotifScreen extends StatefulWidget { - const GuardianSendNotifScreen({super.key}); - - @override - State createState() => - _GuardianSendNotifScreenState(); -} - -class _GuardianSendNotifScreenState extends State { - final _message = TextEditingController(); - bool _loading = false; - - Future _send() async { - setState(() => _loading = true); - try { - await _api.post('/guardian/notifications/send', - data: {'notifType': 'TEXT', 'content': _message.text.trim()}); - _message.clear(); - _snack(context, 'Notifikasi terkirim'); - } on DioException catch (e) { - _snack(context, e.response?.data['message'] ?? 'Gagal mengirim'); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - return _Page( - title: 'Send Notification', - subtitle: 'Text message to paired User', - child: Column( - children: [ - TextField( - controller: _message, - minLines: 4, - maxLines: 6, - decoration: const InputDecoration(labelText: 'Message')), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _loading ? null : _send, - icon: const Icon(Icons.send), - label: const Text('Send'))), - ], - ), - ); - } -} - -class GuardianVoiceCmdScreen extends StatelessWidget { - const GuardianVoiceCmdScreen({super.key}); - @override - Widget build(BuildContext context) => const _EndpointListScreen( - title: 'Voice Commands', endpoint: '/guardian/voice-commands'); -} - -class GuardianShortcutScreen extends StatelessWidget { - const GuardianShortcutScreen({super.key}); - @override - Widget build(BuildContext context) => const _EndpointListScreen( - title: 'Hardware Shortcuts', endpoint: '/guardian/shortcuts'); -} - -class GuardianGeofenceScreen extends StatelessWidget { - const GuardianGeofenceScreen({super.key}); - @override - Widget build(BuildContext context) => const _EndpointActionScreen( - title: 'Geofence', endpoint: '/guardian/geofence'); -} - -class GuardianPairingScreen extends StatefulWidget { - const GuardianPairingScreen({super.key}); - - @override - State createState() => _GuardianPairingScreenState(); -} - -class _GuardianPairingScreenState extends State { - final _id = TextEditingController(); - bool _loading = false; - int _statusReload = 0; - - Future _invite() async { - final uniqueId = _id.text.trim().toUpperCase(); - if (uniqueId.isEmpty || uniqueId.length != 12) { - _snack(context, 'Unique ID harus 12 karakter dari akun User.'); - return; - } - setState(() => _loading = true); - try { - final res = await _api.post('/shared/pairing/invite', - data: {'uniqueUserId': uniqueId}).timeout(const Duration(seconds: 8)); - _snack( - context, - res.data['message']?.toString() ?? - 'Invite terkirim. Minta User buka menu Pairing lalu Accept.'); - setState(() => _statusReload++); - } on DioException catch (e) { - _snack( - context, - _friendlyDioMessage(e, - fallback: - 'Invite gagal. Pastikan kamu login sebagai Guardian dan ID User 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); - } - } - - @override - Widget build(BuildContext context) { - return _Page( - title: 'Pair User', - subtitle: 'Masukkan 12 karakter Unique ID milik User.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - controller: _id, - textCapitalization: TextCapitalization.characters, - maxLength: 12, - decoration: const InputDecoration(labelText: 'Unique User ID')), - FilledButton.icon( - onPressed: _loading ? null : _invite, - icon: _loading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.link), - label: Text(_loading ? 'Sending...' : 'Send Invite'), - ), - const SizedBox(height: 20), - _PairingStatusCard(key: ValueKey(_statusReload)), - ], - ), - ); - } -} - -class _GuardianMapHistoryScreen extends StatelessWidget { - const _GuardianMapHistoryScreen(); - - @override - Widget build(BuildContext context) { - return const _Page( - title: 'Live Map', - subtitle: 'Paired User location and timeline', - child: Column( - children: [ - Expanded( - flex: 3, - child: _MapScreenBody(guardianEndpoint: '/guardian/user-location'), - ), - SizedBox(height: 12), - Expanded(flex: 2, child: ClipRect(child: _LocationTimeline())), - ], - ), - ); - } -} - -class _LocationTimeline extends StatefulWidget { - const _LocationTimeline(); - - @override - State<_LocationTimeline> createState() => _LocationTimelineState(); -} - -class _LocationTimelineState extends State<_LocationTimeline> { - bool _loading = true; - String? _error; - List> _items = const []; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - setState(() { - _loading = true; - _error = null; - }); - try { - final paired = await _hasActivePairing(); - if (!paired) { - _items = const []; - _error = - 'Belum pairing. Timeline lokasi akan muncul setelah Guardian terhubung dengan User.'; - return; - } - final res = await _api.get('/guardian/location-history', - queryParameters: {'size': 80}).timeout(const Duration(seconds: 8)); - final data = res.data['data']; - final content = data is Map ? data['content'] : null; - _items = content is List - ? content - .whereType() - .map((e) => Map.from(e)) - .toList() - : const []; - } on DioException catch (e) { - _error = _friendlyDioMessage(e, - fallback: 'Timeline lokasi belum bisa dimuat.'); - } catch (e) { - _error = 'Timeline lokasi belum bisa dimuat: $e'; - } finally { - if (mounted) setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - if (_loading) return const Center(child: CircularProgressIndicator()); - if (_error != null) { - return _ErrorPanel(message: _error!); - } - if (_items.isEmpty) { - return _EmptyPanel( - icon: Icons.timeline, - title: 'Belum Ada Timeline', - message: - 'Mulai WalkGuide atau buka Map di akun User supaya titik lokasi tersimpan.', - action: OutlinedButton.icon( - onPressed: _load, - icon: const Icon(Icons.refresh), - label: const Text('Refresh')), - ); - } - - final grouped = >>{}; - for (final item in _items) { - final created = - DateTime.tryParse(item['createdAt']?.toString() ?? '')?.toLocal(); - final key = created == null ? 'Unknown time' : '${_two(created.hour)}:00'; - grouped.putIfAbsent(key, () => []).add(item); - } - - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0))), - child: ListView( - padding: const EdgeInsets.all(12), - children: [ - Row( - children: [ - const Expanded( - child: Text('Timeline Lokasi', - style: TextStyle( - fontWeight: FontWeight.w800, fontSize: 16))), - IconButton(onPressed: _load, icon: const Icon(Icons.refresh)), - ], - ), - for (final entry in grouped.entries) ...[ - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 6), - child: Text(entry.key, - style: const TextStyle( - fontWeight: FontWeight.w800, color: Color(0xFF1A56DB))), - ), - for (final item in entry.value) _TimelineTile(data: item), - ], - ], - ), - ); - } -} - -class _TimelineTile extends StatelessWidget { - final Map data; - const _TimelineTile({required this.data}); - - @override - Widget build(BuildContext context) { - final created = - DateTime.tryParse(data['createdAt']?.toString() ?? '')?.toLocal(); - final speed = double.tryParse(data['speed']?.toString() ?? '') ?? 0; - final mode = speed > 6 - ? 'Kendaraan' - : speed > 1.2 - ? 'Jalan cepat' - : 'Jalan kaki / diam'; - final lat = _formatCoord(data['lat']); - final lng = _formatCoord(data['lng']); - return ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon(speed > 6 ? Icons.two_wheeler : Icons.directions_walk, - color: const Color(0xFF1A56DB)), - title: Text( - '${created == null ? '--:--' : '${_two(created.hour)}:${_two(created.minute)}'} $mode'), - subtitle: - Text('Lat $lat, Lng $lng, speed ${speed.toStringAsFixed(1)} m/s'), - ); - } -} - -class AiBenchmarkScreen extends StatefulWidget { - const AiBenchmarkScreen({super.key}); - - @override - State createState() => _AiBenchmarkScreenState(); -} - -class _AiBenchmarkScreenState extends State { - static const _runsKey = 'ai_benchmark_runs'; - List _models = const []; - String _selectedModel = AppConstants.yoloModelPath; - List> _runs = const []; - bool _running = false; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _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.from(jsonDecode(e) as Map)) - .toList() - .reversed - .toList(); - }); - } - - Future _setModel(String? value) async { - if (value == null) return; - await AppConstants.setSelectedYoloModelPath(value); - sl().dispose(); - await sl().init(); - setState(() => _selectedModel = value); - _snack(context, 'Model aktif: ${value.split('/').last}'); - } - - Future _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().isReady; - final detection = await sl().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() - .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 _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.delayed(const Duration(milliseconds: 16)); - } finally { - await controller?.dispose(); - } - watch.stop(); - return watch.elapsedMilliseconds; - } - - Future _clearRuns() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_runsKey); - setState(() => _runs = const []); - } - - @override - Widget build(BuildContext context) { - final hasRealModel = _models.any((model) => model.endsWith('.tflite')); - return _Page( - title: 'AI Benchmark', - subtitle: 'Capture, model, notification text, and TTS timing', - child: ListView( - children: [ - DropdownButtonFormField( - 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 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']}'), - ], - ), - ), - ); - } -} - -class _EndpointListScreen extends StatelessWidget { - final String title; - final String endpoint; - const _EndpointListScreen({required this.title, required this.endpoint}); - - @override - Widget build(BuildContext context) { - return _Page( - title: title, - subtitle: endpoint, - child: _EndpointList(endpoint: endpoint)); - } -} - -class _MapScreen extends StatefulWidget { - final String title; - final String subtitle; - - const _MapScreen({ - required this.title, - required this.subtitle, - }); - - @override - State<_MapScreen> createState() => _MapScreenState(); -} - -class _MapScreenState extends State<_MapScreen> { - final MapController _mapController = MapController(); - LatLng _center = const LatLng(-6.200000, 106.816666); - String _status = 'Loading map...'; - - @override - void initState() { - super.initState(); - _loadLocation(); - } - - Future _loadLocation() async { - try { - await Geolocator.requestPermission(); - final pos = await Geolocator.getCurrentPosition() - .timeout(const Duration(seconds: 8)); - _center = LatLng(pos.latitude, pos.longitude); - _status = 'Lokasi kamu sekarang'; - _mapController.move(_center, 16); - unawaited(_ignoreFailure(_api.post('/user/location', data: { - 'lat': pos.latitude, - 'lng': pos.longitude, - 'accuracy': pos.accuracy, - 'speed': pos.speed, - 'heading': pos.heading, - }).timeout(const Duration(seconds: 5)))); - } catch (_) { - _status = 'GPS belum tersedia. Menampilkan map demo.'; - } finally { - if (mounted) setState(() {}); - } - } - - @override - Widget build(BuildContext context) { - return _Page( - title: widget.title, - subtitle: widget.subtitle, - actions: [ - IconButton( - onPressed: _loadLocation, icon: const Icon(Icons.my_location)) - ], - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Stack( - children: [ - FlutterMap( - mapController: _mapController, - options: MapOptions(initialCenter: _center, initialZoom: 16), - children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.walkguide.app', - ), - MarkerLayer( - markers: [ - Marker( - point: _center, - width: 48, - height: 48, - child: const Icon(Icons.location_pin, - color: Color(0xFFDC2626), size: 44), - ), - ], - ), - ], - ), - Positioned( - left: 12, - right: 12, - bottom: 12, - child: _MapStatus(text: _status)), - ], - ), - ), - ); - } -} - -class _MapScreenBody extends StatefulWidget { - final String? guardianEndpoint; - const _MapScreenBody({this.guardianEndpoint}); - - @override - State<_MapScreenBody> createState() => _MapScreenBodyState(); -} - -class _MapScreenBodyState extends State<_MapScreenBody> { - final MapController _mapController = MapController(); - LatLng _center = const LatLng(-6.200000, 106.816666); - String _status = 'Loading map...'; - - @override - void initState() { - super.initState(); - _loadLocation(); - } - - Future _loadLocation() async { - try { - if (widget.guardianEndpoint != null) { - final res = await _api - .get(widget.guardianEndpoint!) - .timeout(const Duration(seconds: 8)); - final data = res.data['data']; - if (data is Map && data['lat'] != null && data['lng'] != null) { - _center = LatLng( - (data['lat'] as num).toDouble(), (data['lng'] as num).toDouble()); - _status = 'Lokasi user terakhir'; - _mapController.move(_center, 16); - } else { - _status = 'Belum ada lokasi dari user'; - } - } else { - await Geolocator.requestPermission(); - final pos = await Geolocator.getCurrentPosition() - .timeout(const Duration(seconds: 8)); - _center = LatLng(pos.latitude, pos.longitude); - _status = 'Lokasi kamu sekarang'; - _mapController.move(_center, 16); - unawaited(_ignoreFailure(_api.post('/user/location', data: { - 'lat': pos.latitude, - 'lng': pos.longitude, - 'accuracy': pos.accuracy, - 'speed': pos.speed, - 'heading': pos.heading, - }).timeout(const Duration(seconds: 5)))); - } - } catch (_) { - _status = widget.guardianEndpoint == null - ? 'GPS belum tersedia. Menampilkan map demo.' - : 'Lokasi user belum tersedia. Menampilkan map demo.'; - } finally { - if (mounted) setState(() {}); - } - } - - @override - Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Stack( - children: [ - FlutterMap( - mapController: _mapController, - options: MapOptions(initialCenter: _center, initialZoom: 16), - children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.walkguide.app', - ), - MarkerLayer( - markers: [ - Marker( - point: _center, - width: 48, - height: 48, - child: const Icon(Icons.location_pin, - color: Color(0xFFDC2626), size: 44), - ), - ], - ), - ], - ), - Positioned( - right: 12, - top: 12, - child: FloatingActionButton.small( - heroTag: 'map_center_${widget.guardianEndpoint ?? 'user'}', - onPressed: _loadLocation, - child: const Icon(Icons.my_location), - ), - ), - Positioned( - left: 12, - right: 12, - bottom: 12, - child: _MapStatus(text: _status)), - ], - ), - ); - } -} - -class _CallPanel extends StatefulWidget { - final String title; - final String channelName; - const _CallPanel({required this.title, required this.channelName}); - - @override - State<_CallPanel> createState() => _CallPanelState(); -} - -class _CallPanelState extends State<_CallPanel> { - bool _joined = false; - String _status = 'Ready'; - - Future _toggleCall() async { - if (_joined) { - await sl().leave(); - setState(() { - _joined = false; - _status = 'Call ended'; - }); - return; - } - final joined = - await sl().joinChannel(channelName: widget.channelName); - setState(() { - _joined = joined; - _status = joined - ? 'Connected to ${widget.channelName}' - : 'Backend token or Agora App ID not ready'; - }); - } - - @override - Widget build(BuildContext context) { - return _Page( - title: widget.title, - subtitle: 'Agora audio channel', - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(_joined ? Icons.call : Icons.call_outlined, - size: 88, - color: _joined ? Colors.green : const Color(0xFF1A56DB)), - const SizedBox(height: 14), - Text(_status, textAlign: TextAlign.center), - const SizedBox(height: 18), - FilledButton.icon( - onPressed: _toggleCall, - icon: Icon(_joined ? Icons.call_end : Icons.call), - label: Text(_joined ? 'End call' : 'Start call'), - ), - ], - ), - ), - ); - } -} - -class _EndpointActionScreen extends StatelessWidget { - final String title; - final String endpoint; - const _EndpointActionScreen({required this.title, required this.endpoint}); - - @override - Widget build(BuildContext context) { - return _Page( - title: title, - subtitle: endpoint, - child: _EndpointList(endpoint: endpoint), - ); - } -} - -class _EndpointList extends StatefulWidget { - final String endpoint; - const _EndpointList({required this.endpoint}); - - @override - State<_EndpointList> createState() => _EndpointListState(); -} - -class _EndpointListState extends State<_EndpointList> { - Object? _data; - bool _loading = true; - String? _error; - bool _needsPairing = false; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - setState(() => _loading = true); - try { - _error = null; - _needsPairing = false; - if (_endpointNeedsPairing(widget.endpoint)) { - final paired = await _hasActivePairing(); - if (!paired) { - _needsPairing = true; - return; - } - } - final res = - await _api.get(widget.endpoint).timeout(const Duration(seconds: 8)); - _data = res.data['data']; - } on DioException catch (e) { - _error = e.response?.data['message']?.toString() ?? - 'Tidak bisa memuat data dari server.'; - _data = null; - } catch (e) { - _error = 'Timeout / gagal memuat: $e'; - _data = null; - } finally { - if (mounted) setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - if (_loading) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 12), - Text('Memuat ${widget.endpoint}...'), - ], - ), - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - OutlinedButton.icon( - onPressed: _load, - icon: const Icon(Icons.refresh), - label: const Text('Refresh')), - const SizedBox(height: 12), - if (_needsPairing) - _PairingRequiredPanel(endpoint: widget.endpoint) - else if (_error != null) - _ErrorPanel(message: _error!) - else - _JsonCard(data: _data), - ], - ); - } -} - -bool _endpointNeedsPairing(String endpoint) { - return endpoint.contains('/guardian/') || - endpoint.contains('/notifications') || - endpoint.contains('/activity-logs') || - endpoint.contains('/voice-commands') || - endpoint.contains('/shortcuts') || - endpoint.contains('/ai-config') || - endpoint.contains('/geofence'); -} - -Future _hasActivePairing() async { - try { - final res = await _api - .get('/shared/pairing/status') - .timeout(const Duration(seconds: 5)); - final data = res.data['data']; - if (data is Map) return data['status'] == 'ACTIVE'; - } catch (_) {} - return false; -} - -class _PlaceholderScreen extends StatelessWidget { - final String title; - final IconData icon; - final String text; - const _PlaceholderScreen( - {required this.title, required this.icon, required this.text}); - - @override - Widget build(BuildContext context) { - return _Page( - title: title, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 72, color: const Color(0xFF1A56DB)), - const SizedBox(height: 16), - Text(text, textAlign: TextAlign.center), - ], - ), - ), - ); - } -} - -class _Page extends StatelessWidget { - final String title; - final String? subtitle; - final Widget child; - final List? actions; - - const _Page( - {required this.title, required this.child, this.subtitle, this.actions}); - - @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.w800)), - if (subtitle != null) - Text(subtitle!, - style: const TextStyle(color: Color(0xFF64748B))), - ], - ), - ), - ...?actions, - ], - ), - const SizedBox(height: 16), - Expanded(child: child), - ], - ), - ), - ); - } -} - -class _AuthFrame extends StatelessWidget { - final String title; - final String subtitle; - final Widget child; - const _AuthFrame( - {required this.title, required this.subtitle, required this.child}); - - @override - Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460), - child: Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - side: const BorderSide(color: Color(0xFFE2E8F0))), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon(Icons.navigation_rounded, - color: Color(0xFF1A56DB), size: 42), - const SizedBox(height: 14), - Text(title, - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.w800)), - const SizedBox(height: 4), - Text(subtitle, - textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFF64748B))), - const SizedBox(height: 22), - child, - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -class _EmptyPanel extends StatelessWidget { - final IconData icon; - final String title; - final String message; - final Widget? action; - const _EmptyPanel( - {required this.icon, - required this.title, - required this.message, - this.action}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - constraints: const BoxConstraints(minHeight: 0), - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), - 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), - if (action != null) ...[ - const SizedBox(height: 12), - action!, - ], - ], - ), - ); - } -} - -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 _ErrorPanel extends StatelessWidget { - final String message; - const _ErrorPanel({required this.message}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - constraints: const BoxConstraints(minHeight: 180), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFFEF2F2), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFFECACA)), - ), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.error_outline, color: Color(0xFFDC2626)), - const SizedBox(height: 8), - Text(message, - style: const TextStyle( - color: Color(0xFF991B1B), fontWeight: FontWeight.w700)), - const SizedBox(height: 12), - const Text( - 'Kalau ini endpoint Guardian/User, pastikan akun sudah login dengan role yang benar dan pairing sudah dibuat jika endpoint membutuhkan paired user.', - ), - ], - ), - ), - ); - } -} - -class _PairingRequiredPanel extends StatelessWidget { - final String endpoint; - const _PairingRequiredPanel({required this.endpoint}); - - @override - Widget build(BuildContext context) { - final isGuardian = endpoint.startsWith('/guardian'); - return Container( - width: double.infinity, - constraints: const BoxConstraints(minHeight: 240), - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: const Color(0xFFFFFBEB), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFFDE68A)), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.link_off, color: Color(0xFFD97706), size: 56), - const SizedBox(height: 14), - const Text( - 'Belum Pairing', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w800, - color: Color(0xFF92400E)), - ), - const SizedBox(height: 8), - Text( - isGuardian - ? 'Fitur ini butuh User yang sudah terhubung. Masukkan Unique ID User di menu Pairing.' - : 'Fitur ini akan aktif setelah akun kamu terhubung dengan Guardian.', - textAlign: TextAlign.center, - ), - const SizedBox(height: 18), - FilledButton.icon( - onPressed: () => - context.go(isGuardian ? '/guardian/pairing' : '/user/pairing'), - icon: const Icon(Icons.link), - label: const Text('Buka Pairing'), - ), - ], - ), - ); - } -} - -class _PairingStatusCard extends StatefulWidget { - final bool allowUserResponse; - const _PairingStatusCard({super.key, this.allowUserResponse = false}); - - @override - State<_PairingStatusCard> createState() => _PairingStatusCardState(); -} - -class _PairingStatusCardState extends State<_PairingStatusCard> { - String _status = 'Mengecek status pairing...'; - bool _active = false; - bool _loading = false; - bool _responding = false; - Map? _data; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - setState(() => _loading = true); - try { - final token = await sl().getAccessToken(); - if (token == null || token.isEmpty) { - _active = false; - _data = null; - _status = 'Belum login. Login dulu supaya status pairing bisa dicek.'; - return; - } - final res = await _api - .get('/shared/pairing/status') - .timeout(const Duration(seconds: 5)); - final data = res.data['data']; - _data = data is Map ? Map.from(data) : null; - _active = data is Map && data['status'] == 'ACTIVE'; - if (data is Map && data['status'] == 'ACTIVE') { - _active = true; - _status = - 'Sudah pairing dengan ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'akun lain'}.'; - } else if (data is Map && data['status'] == 'PENDING') { - _status = widget.allowUserResponse - ? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.' - : 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.'; - } else { - _status = 'Belum pairing. Bagikan Unique ID kamu ke Guardian.'; - } - } on DioException catch (e) { - _active = false; - _data = null; - _status = _friendlyDioMessage(e, - fallback: - 'Belum bisa mengecek server, tapi Unique ID tetap bisa dibagikan.'); - } 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); - } - } - - Future _respond(bool accept) async { - final pairingId = _data?['pairingId']; - if (pairingId == null) { - _snack(context, 'Tidak ada invite yang bisa direspons.'); - return; - } - setState(() => _responding = true); - try { - final res = await _api.post('/shared/pairing/respond', data: { - 'pairingId': pairingId, - 'accept': accept, - }).timeout(const Duration(seconds: 8)); - _snack( - context, - res.data['message']?.toString() ?? - (accept ? 'Pairing diterima.' : 'Pairing ditolak.')); - await _load(); - } on DioException catch (e) { - _snack(context, - _friendlyDioMessage(e, fallback: 'Gagal merespons pairing.')); - } on TimeoutException { - _snack(context, 'Server terlalu lama merespons pairing.'); - } finally { - if (mounted) setState(() => _responding = false); - } - } - - @override - Widget build(BuildContext context) { - final pending = _data?['status'] == 'PENDING'; - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: _active ? const Color(0xFFF0FDF4) : const Color(0xFFFFFBEB), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: _active ? const Color(0xFFBBF7D0) : const Color(0xFFFDE68A)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Icon(_active ? Icons.link : Icons.info_outline, - color: _active - ? const Color(0xFF16A34A) - : const Color(0xFFD97706)), - const SizedBox(width: 12), - Expanded(child: Text(_status)), - IconButton( - onPressed: _loading ? null : _load, - icon: _loading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.refresh)), - ], - ), - if (widget.allowUserResponse && pending) ...[ - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: _responding ? null : () => _respond(true), - icon: const Icon(Icons.check), - label: const Text('Accept'), - ), - ), - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - onPressed: _responding ? null : () => _respond(false), - icon: const Icon(Icons.close), - label: const Text('Reject'), - ), - ), - ], - ), - ], - ], - ), - ); - } -} - -class _MapStatus extends StatelessWidget { - final String text; - const _MapStatus({required this.text}); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.92), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.12), blurRadius: 18) - ], - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - const Icon(Icons.map, color: Color(0xFF1A56DB)), - const SizedBox(width: 10), - Expanded( - child: Text(text, - style: const TextStyle(fontWeight: FontWeight.w700))), - ], - ), - ), - ); - } -} - -class _JsonCard extends StatelessWidget { - final Object? data; - const _JsonCard({required this.data}); - - @override - Widget build(BuildContext context) { - return Expanded( - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0))), - child: - SingleChildScrollView(child: Text(data?.toString() ?? 'No data')), - ), - ); - } -} - -class _InfoCard extends StatelessWidget { - final String title; - final String value; - final IconData icon; - const _InfoCard( - {required this.title, required this.value, required this.icon}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(12)), - child: Row( - children: [ - Icon(icon, color: const Color(0xFF1A56DB)), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title), - SelectableText(value, - style: const TextStyle( - fontSize: 22, fontWeight: FontWeight.w800)) - ])), - ], - ), - ); - } -} - -class _Pill extends StatelessWidget { - final String text; - final Color color; - const _Pill({required this.text, required this.color}); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: color.withValues(alpha: 0.16), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: color)), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), - child: Text(text, - style: TextStyle(color: color, fontWeight: FontWeight.w800)), - ), - ); - } -} - -Future _saveAuthAndRoute( - BuildContext context, Map data) async { - await sl().saveTokens( - accessToken: data['accessToken'], - refreshToken: data['refreshToken'], - role: data['role'], - userId: data['userId'].toString(), - displayName: data['displayName'], - uniqueUserId: data['uniqueUserId'], - ); - final serverUrl = await AppConstants.getServerUrl(); - if (serverUrl != null) { - context - .read() - .setSession(role: data['role'], serverUrl: serverUrl); - _startPostLoginServices(serverUrl); - } - sl().speak('Selamat datang ${data['displayName'] ?? ''}'); - if (context.mounted) { - context.go(data['role'] == 'ROLE_GUARDIAN' - ? '/guardian/dashboard' - : '/user/walkguide'); - } -} - -void _startPostLoginServices(String serverUrl) { - Future.microtask(() async { - try { - if (!kIsWeb) { - await sl() - .connect(serverUrl) - .timeout(const Duration(seconds: 2)); - } - await sl() - .syncPending(sl()) - .timeout(const Duration(seconds: 3)); - } catch (e) { - debugPrint('Post-login services skipped: $e'); - } - }); -} - -Future _showRegisterSuccess( - BuildContext context, Map data) async { - final uniqueId = data['uniqueUserId']?.toString(); - final message = uniqueId == null || uniqueId.isEmpty - ? 'Registrasi berhasil. Silakan login.' - : 'Registrasi berhasil. Unique User ID kamu: $uniqueId. Silakan login.'; - _snack(context, message); - await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Register Successful'), - content: SelectableText(message), - actions: [ - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Login sekarang'), - ), - ], - ), - ); -} - -void _snack(BuildContext context, String message) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .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 'Sesi login habis. Logout lalu login ulang.'; - } - if (e.response?.statusCode == 403) { - 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; -} - -Future> _discoverTfliteModels() async { - try { - final manifestRaw = await rootBundle.loadString('AssetManifest.json'); - final manifest = jsonDecode(manifestRaw) as Map; - 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'); - -String _formatCoord(Object? value) { - if (value is num) return value.toStringAsFixed(6); - final parsed = double.tryParse(value?.toString() ?? ''); - return parsed == null ? '-' : parsed.toStringAsFixed(6); -} - -Future _ignoreFailure(Future future) async { - try { - await future; - } catch (_) {} -} diff --git a/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart b/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart index 49bc11d..bf1b0f4 100644 --- a/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart +++ b/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import '../../app/injection_container.dart'; import '../../core/constants/app_constants.dart'; +import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; // --------------------------------------------------------------------------- @@ -37,21 +38,22 @@ class _ServerConnectScreenState extends State { _ok = false; _message = null; }); - try { - final clean = AppConstants.normalizeServerUrl(_url.text); - final res = await Dio(BaseOptions( - connectTimeout: AppConstants.pingTimeout, - receiveTimeout: AppConstants.pingTimeout, - )).get('$clean/api/v1/auth/ping'); - _ok = res.statusCode == 200 && res.data['success'] == true; - _message = _ok - ? 'Server aktif dan siap dipakai.' - : 'Server merespons dengan format tidak valid.'; - } catch (e) { - _message = 'Tidak bisa terhubung. Periksa URL dan jaringan.'; - } finally { - if (mounted) setState(() => _loading = false); - } + await runFriendlyAction( + () async { + final clean = AppConstants.normalizeServerUrl(_url.text); + final res = await Dio(BaseOptions( + connectTimeout: AppConstants.pingTimeout, + receiveTimeout: AppConstants.pingTimeout, + )).get('$clean/api/v1/auth/ping'); + _ok = res.statusCode == 200 && res.data['success'] == true; + _message = _ok + ? 'Server aktif dan siap dipakai.' + : 'Server merespons dengan format tidak valid.'; + }, + onError: (message) => _message = message, + fallback: 'Tidak bisa terhubung. Periksa URL dan jaringan.', + ); + if (mounted) setState(() => _loading = false); } Future _continue() async { diff --git a/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart b/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart index a686376..91f011b 100644 --- a/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart @@ -12,6 +12,7 @@ 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/services/haptic_service.dart'; import '../../core/services/tts_service.dart'; @@ -78,40 +79,46 @@ class _UserSettingsScreenState extends State { } Future _loadSettings() async { - try { - final res = - await _api.get('/user/settings').timeout(const Duration(seconds: 6)); - final data = res.data['data']; - if (data is Map) { - _ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID'; - _ttsPitch = (data['ttsPitch'] as num?)?.toDouble() ?? 1.0; - _ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9; - _warnNoGuardian = data['warnNoGuardian'] as bool? ?? true; - _hapticEnabled = data['hapticEnabled'] as bool? ?? true; - } - } catch (_) { - // offline: tetap pakai default / nilai lokal - } + await runFriendlyAction( + () async { + final res = await _api + .get('/user/settings') + .timeout(const Duration(seconds: 6)); + final data = res.data['data']; + if (data is Map) { + _ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID'; + _ttsPitch = (data['ttsPitch'] as num?)?.toDouble() ?? 1.0; + _ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9; + _warnNoGuardian = data['warnNoGuardian'] as bool? ?? true; + _hapticEnabled = data['hapticEnabled'] as bool? ?? true; + } + }, + onError: (_) {}, + fallback: 'Settings belum bisa dimuat.', + ); } Future _loadPairing() async { - try { - final res = await _api - .get('/shared/pairing/status') - .timeout(const Duration(seconds: 5)); - final data = res.data['data']; - if (data is Map) { - _paired = data['status'] == 'ACTIVE'; - final partner = data['pairedWithName'] ?? data['pairedWithEmail'] ?? ''; - _pairingStatus = _paired - ? 'Terhubung dengan $partner' - : data['status'] == 'PENDING' - ? 'Menunggu konfirmasi Guardian' - : 'Belum paired'; - } - } catch (_) { - _pairingStatus = 'Tidak bisa cek status'; - } + await runFriendlyAction( + () async { + final res = await _api + .get('/shared/pairing/status') + .timeout(const Duration(seconds: 5)); + final data = res.data['data']; + if (data is Map) { + _paired = data['status'] == 'ACTIVE'; + final partner = + data['pairedWithName'] ?? data['pairedWithEmail'] ?? ''; + _pairingStatus = _paired + ? 'Terhubung dengan $partner' + : data['status'] == 'PENDING' + ? 'Menunggu konfirmasi Guardian' + : 'Belum paired'; + } + }, + onError: (_) => _pairingStatus = 'Tidak bisa cek status', + fallback: 'Tidak bisa cek status', + ); } Future _save() async { @@ -122,34 +129,28 @@ class _UserSettingsScreenState extends State { await sl().success(); } - try { - await _api.put('/user/settings', data: { - 'ttsLanguage': _ttsLanguage, - 'ttsPitch': _ttsPitch, - 'ttsSpeed': _ttsSpeed, - 'warnNoGuardian': _warnNoGuardian, - 'hapticEnabled': _hapticEnabled, - }).timeout(const Duration(seconds: 8)); - _snack('Settings tersimpan.'); - sl().speak('Settings disimpan.'); - } on DioException catch (e) { - final msg = e.response?.data['message']?.toString() ?? - 'Server tidak merespons, settings lokal sudah diterapkan.'; - _snack(msg); - } catch (_) { - _snack('Settings lokal sudah diterapkan, gagal sync ke server.'); - } finally { - if (mounted) setState(() => _saving = false); - } + await runFriendlyAction( + () async { + await _api.put('/user/settings', data: { + 'ttsLanguage': _ttsLanguage, + 'ttsPitch': _ttsPitch, + 'ttsSpeed': _ttsSpeed, + 'warnNoGuardian': _warnNoGuardian, + 'hapticEnabled': _hapticEnabled, + }).timeout(const Duration(seconds: 8)); + _snack('Settings tersimpan.'); + sl().speak('Settings disimpan.'); + }, + onError: _snack, + fallback: 'Settings lokal sudah diterapkan, gagal sync ke server.', + ); + if (mounted) setState(() => _saving = false); } Future _logout() async { await sl().clearAll(); context.read().clearSession(); - _api - .post('/auth/logout') - .timeout(const Duration(seconds: 3)) - .ignore(); + _api.post('/auth/logout').timeout(const Duration(seconds: 3)).ignore(); if (mounted) context.go('/login'); } diff --git a/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart b/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart index 6a142b9..608f855 100644 --- a/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import '../../app/injection_container.dart'; +import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/services/haptic_service.dart'; import '../../core/services/tts_service.dart'; @@ -94,15 +95,17 @@ class _SosScreenState extends State // ── API Calls ───────────────────────────────────────────────────────────── Future _getPosition() async { - try { - final permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) return null; - return await Geolocator.getCurrentPosition() - .timeout(const Duration(seconds: 6)); - } catch (_) { - return null; - } + return runFriendly( + () async { + final permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) return null; + return await Geolocator.getCurrentPosition() + .timeout(const Duration(seconds: 6)); + }, + onError: (_) {}, + fallback: 'Lokasi belum bisa dibaca.', + ); } Future _loadHistory() async { @@ -110,26 +113,24 @@ class _SosScreenState extends State _historyLoading = true; _historyError = null; }); - try { - final res = await _api.get('/user/sos-events', - queryParameters: {'size': 10}).timeout(const Duration(seconds: 8)); - final data = res.data['data']; - final content = data is Map ? data['content'] : null; - final items = content is List - ? content - .whereType() - .map((e) => _SosEvent.fromMap(Map.from(e))) - .toList() - : <_SosEvent>[]; - setState(() => _events = items); - } on DioException catch (e) { - final msg = e.response?.data?['message']?.toString(); - setState(() => _historyError = msg ?? 'Tidak bisa memuat riwayat SOS.'); - } catch (_) { - setState(() => _historyError = 'Tidak bisa memuat riwayat SOS.'); - } finally { - if (mounted) setState(() => _historyLoading = false); - } + await runFriendlyAction( + () async { + final res = await _api.get('/user/sos-events', + queryParameters: {'size': 10}).timeout(const Duration(seconds: 8)); + final data = res.data['data']; + final content = data is Map ? data['content'] : null; + final items = content is List + ? content + .whereType() + .map((e) => _SosEvent.fromMap(Map.from(e))) + .toList() + : <_SosEvent>[]; + setState(() => _events = items); + }, + onError: (message) => setState(() => _historyError = message), + fallback: 'Tidak bisa memuat riwayat SOS.', + ); + if (mounted) setState(() => _historyLoading = false); } Future _confirmAndSend() async { @@ -178,25 +179,23 @@ class _SosScreenState extends State Future _sendSos() async { setState(() => _sending = true); - try { - final pos = await _getPosition(); - await _api.post('/user/sos', data: { - 'triggerType': 'BUTTON', - 'lat': pos?.latitude, - 'lng': pos?.longitude, - }); - await sl().sosTriggered(); - sl().speak('SOS terkirim ke Guardian.'); - _snack('SOS berhasil dikirim! Guardian sudah diberitahu.'); - await _loadHistory(); - } on DioException catch (e) { - final msg = e.response?.data?['message']?.toString() ?? 'Gagal kirim SOS'; - _snack(msg); - } catch (e) { - _snack('Gagal kirim SOS: $e'); - } finally { - if (mounted) setState(() => _sending = false); - } + await runFriendlyAction( + () async { + final pos = await _getPosition(); + await _api.post('/user/sos', data: { + 'triggerType': 'BUTTON', + 'lat': pos?.latitude, + 'lng': pos?.longitude, + }); + await sl().sosTriggered(); + sl().speak('SOS terkirim ke Guardian.'); + _snack('SOS berhasil dikirim! Guardian sudah diberitahu.'); + await _loadHistory(); + }, + onError: _snack, + fallback: 'Gagal kirim SOS. Coba lagi sebentar.', + ); + if (mounted) setState(() => _sending = false); } // ── Build ────────────────────────────────────────────────────────────────── diff --git a/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart b/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart index ddc85ed..f8f40a0 100644 --- a/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart @@ -1,17 +1,14 @@ // ignore_for_file: use_build_context_synchronously, deprecated_member_use import 'dart:async'; -import 'dart:convert'; import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:go_router/go_router.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/network/api_client.dart'; import '../../core/services/haptic_service.dart'; import '../../core/services/location_reporter_service.dart'; @@ -36,6 +33,7 @@ class _WalkGuideScreenState extends State { bool _processingFrame = false; DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0); + DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0); @override void dispose() { @@ -59,7 +57,7 @@ class _WalkGuideScreenState extends State { } setState(() { _active = next; - _status = next ? 'Camera stream active. YOLO ready.' : 'Stopped'; + _status = next ? _activeStatusText() : 'Stopped'; }); await sl() .dio @@ -67,13 +65,30 @@ class _WalkGuideScreenState extends State { sl().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan'); } + String _activeStatusText() { + final detector = sl(); + 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 _startCamera() async { if (_camera != null) return; try { final cameras = await availableCameras(); if (cameras.isEmpty) return; + final backCamera = cameras.firstWhere( + (camera) => camera.lensDirection == CameraLensDirection.back, + orElse: () => cameras.first, + ); final controller = CameraController( - cameras.first, + backCamera, ResolutionPreset.medium, enableAudio: false, imageFormatGroup: ImageFormatGroup.yuv420, @@ -86,11 +101,13 @@ class _WalkGuideScreenState extends State { try { await controller.startImageStream(_onCameraImage); } 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); } catch (_) { - setState(() => _status = 'Camera unavailable. Demo mode active.'); + setState(() => _status = 'Camera unavailable.'); } } @@ -118,24 +135,28 @@ class _WalkGuideScreenState extends State { } Future _runYolo(CameraImage image) async { - final detection = await sl().detect(image); - if (detection == null || !mounted) return; + final detector = sl(); + final detection = await detector.detect(image, confidenceThreshold: 0.25); + 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; + } await _handleDetection(detection); } - Future _simulateObstacle() async { - final detection = await sl().detectFallback(); - if (detection == null) return; - await _handleDetection(detection, forceAlert: true); - } - Future _handleDetection( DetectionResult detection, { bool forceAlert = false, }) async { _lastDetection = detection; setState(() => _status = - 'Obstacle: ${detection.label} ${detection.directionName} ${detection.estimatedDistance}'); + 'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}'); final now = DateTime.now(); if (!forceAlert && @@ -187,6 +208,12 @@ class _WalkGuideScreenState extends State { const Center( child: Icon(Icons.videocam_outlined, color: Colors.white30, size: 96)), + if (_lastDetection?.box != null) + Positioned.fill( + child: CustomPaint( + painter: _DetectionOverlayPainter(_lastDetection!), + ), + ), Positioned( top: 16, left: 16, @@ -199,7 +226,7 @@ class _WalkGuideScreenState extends State { left: 16, child: _Pill( text: - '${_lastDetection!.label} ${_lastDetection!.directionName}', + '${ObstacleAnalyzer.spokenLabel(_lastDetection!.label)} ${_lastDetection!.directionName}', color: Colors.redAccent), ), Positioned( @@ -223,12 +250,6 @@ class _WalkGuideScreenState extends State { onPressed: _toggle, icon: Icon(_active ? Icons.stop : Icons.play_arrow), 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 { } } -// --------------------------------------------------------------------------- -// AiBenchmarkScreen -// --------------------------------------------------------------------------- - -class AiBenchmarkScreen extends StatefulWidget { - const AiBenchmarkScreen({super.key}); +class _DetectionOverlayPainter extends CustomPainter { + final DetectionResult detection; + const _DetectionOverlayPainter(this.detection); @override - State createState() => _AiBenchmarkScreenState(); -} + void paint(Canvas canvas, Size size) { + final box = detection.box; + if (box == null) return; -class _AiBenchmarkScreenState extends State { - static const _runsKey = 'ai_benchmark_runs'; - List _models = const []; - String _selectedModel = AppConstants.yoloModelPath; - List> _runs = const []; - bool _running = false; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _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.from(jsonDecode(e) as Map)) - .toList() - .reversed - .toList(); - }); - } - - Future _setModel(String? value) async { - if (value == null) return; - await AppConstants.setSelectedYoloModelPath(value); - sl().dispose(); - await sl().init(); - setState(() => _selectedModel = value); - _snack(context, 'Model aktif: ${value.split('/').last}'); - } - - Future _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().isReady; - final detection = await sl().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() - .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 sx = size.width / ObstacleAnalyzer.frameWidth; + final sy = size.height / ObstacleAnalyzer.frameHeight; + final rect = Rect.fromLTRB( + box.left * sx, + box.top * sy, + box.right * sx, + box.bottom * sy, + ); + final color = switch (detection.direction) { + ObstacleDirection.center => const Color(0xFFEF4444), + ObstacleDirection.left => const Color(0xFFF59E0B), + ObstacleDirection.right => const Color(0xFFF59E0B), }; - 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 _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.delayed(const Duration(milliseconds: 16)); - } finally { - await controller?.dispose(); - } - watch.stop(); - return watch.elapsedMilliseconds; - } - - Future _clearRuns() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_runsKey); - setState(() => _runs = const []); - } - - @override - Widget build(BuildContext context) { - final hasRealModel = _models.any((model) => model.endsWith('.tflite')); - return _Page( - title: 'AI Benchmark', - subtitle: 'Capture, model, notification text, and TTS timing', - child: ListView( - children: [ - DropdownButtonFormField( - 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.', - ), - ], - ), + canvas.drawRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(10)), + Paint() + ..color = color.withValues(alpha: 0.12) + ..style = PaintingStyle.fill, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(10)), + Paint() + ..color = color + ..strokeWidth = 3 + ..style = PaintingStyle.stroke, ); - } -} -class _BenchmarkCard extends StatelessWidget { - final Map 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']}'), - ], + final label = + '${ObstacleAnalyzer.spokenLabel(detection.label)} ${(detection.confidence * 100).round()}% ${detection.directionName}'; + final textPainter = TextPainter( + 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 + bool shouldRepaint(covariant _DetectionOverlayPainter oldDelegate) { + return oldDelegate.detection != detection; } } @@ -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> _discoverTfliteModels() async { - try { - final manifestRaw = await rootBundle.loadString('AssetManifest.json'); - final manifest = jsonDecode(manifestRaw) as Map; - 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))); - } -} diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart new file mode 100644 index 0000000..9b749d6 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart @@ -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'), + ), + ], + ], + ), + ), + ); + } +}