From a629357e8c4b2c536791f60464b6236eec9eedb9 Mon Sep 17 00:00:00 2001 From: Wowieee4 Date: Tue, 26 May 2026 21:09:13 +0700 Subject: [PATCH] Fix local dev config and ALOTTTT OFF FLUTTER, im tired boss.. --- .../controller/GuardianController.java | 19 +- .../walkguide/controller/UserController.java | 28 +- .../service/HardwareShortcutService.java | 18 ++ .../com/walkguide/service/SosService.java | 37 +++ .../com/walkguide/service/UserService.java | 40 +++ .../service/VoiceCommandService.java | 7 + .../src/main/resources/application-dev.yml | 8 +- .../src/main/resources/application.properties | 10 +- .../demo/src/main/resources/openapi.yaml | 12 + .../com/walkguide/DemoApplicationTests.java | 4 +- .../controller/GuardianControllerTest.java | 43 +++- .../integration/AuthIntegrationTest.java | 7 +- .../integration/PairingIntegrationTest.java | 7 +- .../UserFeatureIntegrationTest.java | 7 +- .../service/HardwareShortcutServiceTest.java | 60 ++++- .../com/walkguide/service/SosServiceTest.java | 50 +++- .../PHYSICAL_DEVICE_BENCHMARK_CHECKLIST.md | 16 ++ .../lib/app/injection_container.dart | 48 +++- .../walkguide_app/lib/app/router.dart | 67 +++-- .../lib/core/ai/obstacle_alert_strategy.dart | 57 +++++ .../walkguide_app/lib/core/api_service.dart | 35 ++- .../lib/core/i18n/app_strings.dart | 31 +++ .../services/hardware_shortcut_listener.dart | 131 ++++++++++ .../core/services/offline_queue_service.dart | 54 ++-- .../storage/local_cache_store_native.dart | 51 ++++ .../core/storage/local_cache_store_web.dart | 22 ++ .../lib/core/storage/local_database.dart | 239 ++++++++++++++++++ .../lib/core/utils/init_guard.dart | 13 + .../lib/core/utils/operation_guard.dart | 18 ++ .../screens/activity_log_screen.dart | 1 + .../ai_benchmark/ai_benchmark_screen.dart | 36 +-- .../screens/ai_benchmark_screen.dart | 1 + .../auth/data/auth_remote_data_source.dart | 10 +- .../presentation/screens/call_screen.dart | 1 + .../guardian_ai_config_screen.dart | 122 +++++---- .../guardian_send_notification_screen.dart | 175 ++++++++++++- .../guardian_tools_screen.dart | 228 ++++++++++++++++- .../screens/guardian_activity_log_screen.dart | 1 + .../screens/guardian_ai_config_screen.dart | 1 + .../screens/guardian_map_screen.dart | 1 + .../guardian_send_notification_screen.dart | 1 + .../screens/guardian_settings_screen.dart | 1 + .../screens/guardian_tools_screen.dart | 1 + .../guardian_dashboard_screen.dart | 124 ++++++--- .../navigation_mode_screen.dart | 46 ++-- .../screens/navigation_mode_screen.dart | 1 + .../application/notification_cubit.dart | 91 +++++++ .../notification_repository_impl.dart | 90 +++++++ .../entities/guardian_notification.dart | 19 ++ .../repositories/notification_repository.dart | 10 + .../notifications/notification_screen.dart | 189 +++++++------- .../screens/notification_screen.dart | 1 + .../presentation/screens/pairing_screens.dart | 1 + .../server_connect/server_connect_server.dart | 4 +- .../screens/user_settings_screen.dart | 1 + .../features/sos/application/sos_cubit.dart | 38 +++ .../repositories/sos_repository_impl.dart | 29 +++ .../sos/domain/entities/sos_event.dart | 17 ++ .../domain/repositories/sos_repository.dart | 11 + .../sos/presentation/screens/sos_screen.dart | 1 + .../lib/features/sos/sos_screen.dart | 59 +++-- .../application/walk_guide_cubit.dart | 64 +++++ .../walk_guide_repository_impl.dart | 46 ++++ .../domain/entities/walk_session.dart | 13 + .../repositories/walk_guide_repository.dart | 9 + .../screens/walk_guide_screen.dart | 1 + .../walk_guide/walk_guide_screen.dart | 237 ++++++++--------- walkguide-mobile/walkguide_app/lib/main.dart | 18 +- .../lib/shared/widgets/app_shells.dart | 115 +++++++++ walkguide-mobile/walkguide_app/pubspec.lock | 18 +- walkguide-mobile/walkguide_app/pubspec.yaml | 1 + .../integration_test/live_api_e2e_test.dart | 123 +++++++++ 72 files changed, 2566 insertions(+), 530 deletions(-) create mode 100644 walkguide-backend/demo/src/main/java/com/walkguide/service/UserService.java create mode 100644 walkguide-mobile/walkguide_app/benchmark/PHYSICAL_DEVICE_BENCHMARK_CHECKLIST.md create mode 100644 walkguide-mobile/walkguide_app/lib/core/ai/obstacle_alert_strategy.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/i18n/app_strings.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/services/hardware_shortcut_listener.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/storage/local_cache_store_native.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/storage/local_cache_store_web.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/storage/local_database.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/utils/init_guard.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/utils/operation_guard.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/activity_log/presentation/screens/activity_log_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/ai_benchmark/presentation/screens/ai_benchmark_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/call/presentation/screens/call_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_activity_log_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_ai_config_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_map_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_send_notification_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_settings_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_tools_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/navigation_mode/presentation/screens/navigation_mode_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/notifications/application/notification_cubit.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/notifications/data/repositories/notification_repository_impl.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/notifications/domain/entities/guardian_notification.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/notifications/domain/repositories/notification_repository.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/notifications/presentation/screens/notification_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/pairing/presentation/screens/pairing_screens.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/settings/presentation/screens/user_settings_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/sos/application/sos_cubit.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/sos/data/repositories/sos_repository_impl.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/sos/domain/entities/sos_event.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/sos/domain/repositories/sos_repository.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/sos/presentation/screens/sos_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/walk_guide/application/walk_guide_cubit.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/walk_guide/data/repositories/walk_guide_repository_impl.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/walk_guide/domain/entities/walk_session.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/walk_guide/domain/repositories/walk_guide_repository.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/walk_guide/presentation/screens/walk_guide_screen.dart create mode 100644 walkguide-mobile/walkguide_app/test/integration_test/live_api_e2e_test.dart 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 ab42590..3ebcff0 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 @@ -98,6 +98,13 @@ public class GuardianController { "SOS diakui")); } + @PutMapping("/sos/{id}/resolve") + public ResponseEntity> resolveSos(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.ok( + sosService.resolveSos(SecurityHelper.getCurrentUserId(), id), + "SOS ditangani")); + } + @GetMapping("/ai-config") public ResponseEntity> getAiConfig() { // Guardian lihat config user yang dipair @@ -117,7 +124,7 @@ public class GuardianController { @GetMapping("/voice-commands") public ResponseEntity> getVoiceCommands() { return ResponseEntity.ok(ApiResponse.ok( - voiceCommandService.getAll(SecurityHelper.getCurrentUserId()), + voiceCommandService.getAllForGuardian(SecurityHelper.getCurrentUserId()), "Voice commands")); } @@ -132,10 +139,18 @@ public class GuardianController { @GetMapping("/shortcuts") public ResponseEntity> getShortcuts() { return ResponseEntity.ok(ApiResponse.ok( - hardwareShortcutService.getAll(SecurityHelper.getCurrentUserId()), + hardwareShortcutService.getAllForGuardian(SecurityHelper.getCurrentUserId()), "Hardware shortcuts")); } + @PutMapping("/shortcuts") + public ResponseEntity> updateShortcut( + @RequestBody HardwareShortcutUpdateRequest req) { + return ResponseEntity.ok(ApiResponse.ok( + hardwareShortcutService.updateByGuardian(SecurityHelper.getCurrentUserId(), req), + "Hardware shortcut diperbarui")); + } + @GetMapping("/geofence") public ResponseEntity> getGeofence() { return ResponseEntity.ok(ApiResponse.ok( diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/controller/UserController.java b/walkguide-backend/demo/src/main/java/com/walkguide/controller/UserController.java index e1af2b1..54ab874 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/controller/UserController.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/controller/UserController.java @@ -3,8 +3,6 @@ package com.walkguide.controller; import com.walkguide.dto.ApiResponse; import com.walkguide.dto.request.*; import com.walkguide.dto.response.*; -import com.walkguide.enums.ActivityLogType; -import com.walkguide.repository.UserRepository; import com.walkguide.security.SecurityHelper; import com.walkguide.service.*; import lombok.RequiredArgsConstructor; @@ -29,21 +27,13 @@ public class UserController { private final AiConfigService aiConfigService; private final VoiceCommandService voiceCommandService; private final HardwareShortcutService hardwareShortcutService; - private final UserRepository userRepository; + private final UserService userService; @GetMapping("/profile") public ResponseEntity> getProfile() { - Long userId = SecurityHelper.getCurrentUserId(); - var user = userRepository.findById(userId) - .orElseThrow(() -> new RuntimeException("User tidak ditemukan")); - var profile = java.util.Map.of( - "id", user.getId(), - "email", user.getEmail(), - "displayName", user.getDisplayName() != null ? user.getDisplayName() : "", - "role", user.getRole(), - "uniqueUserId", user.getUniqueUserId() != null ? user.getUniqueUserId() : "" - ); - return ResponseEntity.ok(ApiResponse.ok(profile, "Profil user")); + return ResponseEntity.ok(ApiResponse.ok( + userService.getProfile(SecurityHelper.getCurrentUserId()), + "Profil user")); } @GetMapping("/settings") @@ -163,19 +153,13 @@ public class UserController { @PostMapping("/walkguide/start") public ResponseEntity> walkGuideStart() { - Long userId = SecurityHelper.getCurrentUserId(); - userRepository.findById(userId).ifPresent(u -> - activityLogService.createLog(u, ActivityLogType.WALKGUIDE_START, - "WalkGuide dimulai", null)); + userService.logWalkGuideStart(SecurityHelper.getCurrentUserId()); return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dimulai")); } @PostMapping("/walkguide/stop") public ResponseEntity> walkGuideStop() { - Long userId = SecurityHelper.getCurrentUserId(); - userRepository.findById(userId).ifPresent(u -> - activityLogService.createLog(u, ActivityLogType.WALKGUIDE_STOP, - "WalkGuide dihentikan", null)); + userService.logWalkGuideStop(SecurityHelper.getCurrentUserId()); return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dihentikan")); } } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/HardwareShortcutService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/HardwareShortcutService.java index 30c624c..dc40ba5 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/HardwareShortcutService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/HardwareShortcutService.java @@ -2,9 +2,12 @@ package com.walkguide.service; import com.walkguide.dto.request.HardwareShortcutUpdateRequest; import com.walkguide.dto.response.HardwareShortcutResponse; +import com.walkguide.enums.PairingStatus; import com.walkguide.enums.HardwareShortcutKey; +import com.walkguide.exception.PairingException; import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.repository.HardwareShortcutRepository; +import com.walkguide.repository.PairingRelationRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.List; @@ -15,6 +18,7 @@ import java.util.stream.Collectors; public class HardwareShortcutService { private final HardwareShortcutRepository hardwareShortcutRepository; + private final PairingRelationRepository pairingRelationRepository; public List getAll(Long userId) { return hardwareShortcutRepository.findByUserId(userId).stream() @@ -25,6 +29,13 @@ public class HardwareShortcutService { .collect(Collectors.toList()); } + public List getAllForGuardian(Long guardianId) { + var pairing = pairingRelationRepository + .findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE) + .orElseThrow(() -> new PairingException("Tidak ada user yang dipair")); + return getAll(pairing.getUser().getId()); + } + // Bisa dipanggil dari HP User langsung (capture button) atau dari Guardian public HardwareShortcutResponse update(Long userId, HardwareShortcutUpdateRequest req) { HardwareShortcutKey key = HardwareShortcutKey.valueOf(req.getShortcutKey()); @@ -43,4 +54,11 @@ public class HardwareShortcutService { .buttonName(shortcut.getButtonName()).buttonCode(shortcut.getButtonCode()) .enabled(shortcut.getEnabled()).build(); } + + public HardwareShortcutResponse updateByGuardian(Long guardianId, HardwareShortcutUpdateRequest req) { + var pairing = pairingRelationRepository + .findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE) + .orElseThrow(() -> new PairingException("Tidak ada user yang dipair")); + return update(pairing.getUser().getId(), req); + } } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java index 8558452..bd68ab0 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java @@ -87,6 +87,7 @@ public class SosService { public SosEventResponse acknowledgeSos(Long guardianId, Long sosId) { SosEvent sos = sosEventRepository.findById(sosId) .orElseThrow(() -> new ResourceNotFoundException("SOS event tidak ditemukan")); + assertGuardianOwnsSos(guardianId, sos.getUserId()); sos.setStatus(SosStatus.ACKNOWLEDGED); sos.setAcknowledgedAt(LocalDateTime.now()); @@ -111,6 +112,33 @@ public class SosService { return toResponse(sos); } + @Transactional + public SosEventResponse resolveSos(Long guardianId, Long sosId) { + SosEvent sos = sosEventRepository.findById(sosId) + .orElseThrow(() -> new ResourceNotFoundException("SOS event tidak ditemukan")); + assertGuardianOwnsSos(guardianId, sos.getUserId()); + + if (sos.getAcknowledgedAt() == null) { + sos.setAcknowledgedAt(LocalDateTime.now()); + } + sos.setStatus(SosStatus.RESOLVED); + sosEventRepository.save(sos); + + User user = userRepository.findById(sos.getUserId()) + .orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan")); + activityLogService.createLog(user, ActivityLogType.SOS_ACKNOWLEDGED, + "Guardian menandai SOS sudah ditangani", null); + + pairingRelationRepository.findByUser_IdAndStatus(sos.getUserId(), PairingStatus.ACTIVE) + .ifPresent(pairing -> fcmService.sendToToken( + pairing.getUser().getFcmToken(), + "SOS Sudah Ditangani", + "Guardian kamu sudah menandai SOS sebagai selesai.", + Map.of("type", "SOS_RESOLVED"))); + + return toResponse(sos); + } + public Page getSosEvents(Long userId, Pageable pageable) { return sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable) .map(this::toResponse); @@ -125,6 +153,15 @@ public class SosService { .map(this::toResponse); } + private void assertGuardianOwnsSos(Long guardianId, Long userId) { + var pairing = pairingRelationRepository + .findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE) + .orElseThrow(() -> new ResourceNotFoundException("Tidak ada user yang dipair")); + if (!pairing.getUser().getId().equals(userId)) { + throw new ResourceNotFoundException("SOS event tidak ditemukan untuk guardian ini"); + } + } + private SosEventResponse toResponse(SosEvent s) { return SosEventResponse.builder() .id(s.getId()) diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/UserService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/UserService.java new file mode 100644 index 0000000..f84c51f --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/UserService.java @@ -0,0 +1,40 @@ +package com.walkguide.service; + +import com.walkguide.enums.ActivityLogType; +import com.walkguide.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final ActivityLogService activityLogService; + + public Map getProfile(Long userId) { + var user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("User tidak ditemukan")); + return Map.of( + "id", user.getId(), + "email", user.getEmail(), + "displayName", user.getDisplayName() != null ? user.getDisplayName() : "", + "role", user.getRole(), + "uniqueUserId", user.getUniqueUserId() != null ? user.getUniqueUserId() : "" + ); + } + + public void logWalkGuideStart(Long userId) { + userRepository.findById(userId).ifPresent(user -> + activityLogService.createLog(user, ActivityLogType.WALKGUIDE_START, + "WalkGuide dimulai", null)); + } + + public void logWalkGuideStop(Long userId) { + userRepository.findById(userId).ifPresent(user -> + activityLogService.createLog(user, ActivityLogType.WALKGUIDE_STOP, + "WalkGuide dihentikan", null)); + } +} diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/VoiceCommandService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/VoiceCommandService.java index 7dd53a5..6b00a93 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/VoiceCommandService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/VoiceCommandService.java @@ -52,6 +52,13 @@ public class VoiceCommandService { .collect(Collectors.toList()); } + public List getAllForGuardian(Long guardianId) { + var pairing = pairingRelationRepository + .findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE) + .orElseThrow(() -> new PairingException("Tidak ada user yang dipair")); + return getAll(pairing.getUser().getId()); + } + public VoiceCommandResponse updateByGuardian(Long guardianId, VoiceCommandUpdateRequest req) { var pairing = pairingRelationRepository .findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE) diff --git a/walkguide-backend/demo/src/main/resources/application-dev.yml b/walkguide-backend/demo/src/main/resources/application-dev.yml index 2e355b6..41a46e8 100644 --- a/walkguide-backend/demo/src/main/resources/application-dev.yml +++ b/walkguide-backend/demo/src/main/resources/application-dev.yml @@ -6,9 +6,9 @@ spring: datasource: - url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} + url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001} + username: ${DB_USERNAME:5803024001} + password: ${DB_PASSWORD:pw5803024001} jpa: show-sql: true @@ -17,7 +17,7 @@ spring: format_sql: true jwt: - secret: ${JWT_SECRET} + secret: ${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt} expiration: ${JWT_EXPIRATION:86400000} agora: diff --git a/walkguide-backend/demo/src/main/resources/application.properties b/walkguide-backend/demo/src/main/resources/application.properties index a7b2647..c5cea7b 100644 --- a/walkguide-backend/demo/src/main/resources/application.properties +++ b/walkguide-backend/demo/src/main/resources/application.properties @@ -2,9 +2,9 @@ server.port=${SERVER_PORT:8080} # ===== POSTGRESQL CONNECTION ===== -spring.datasource.url=${DB_URL} -spring.datasource.username=${DB_USERNAME} -spring.datasource.password=${DB_PASSWORD} +spring.datasource.url=${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001} +spring.datasource.username=${DB_USERNAME:5803024001} +spring.datasource.password=${DB_PASSWORD:pw5803024001} spring.datasource.driver-class-name=org.postgresql.Driver # ===== JPA / HIBERNATE ===== @@ -19,7 +19,7 @@ spring.flyway.locations=classpath:db/migration spring.flyway.baseline-on-migrate=true # ===== JWT ===== -jwt.secret=${JWT_SECRET} +jwt.secret=${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt} jwt.expiration=${JWT_EXPIRATION:86400000} # ===== SWAGGER ===== @@ -38,4 +38,4 @@ logging.level.com.walkguide=DEBUG logging.level.org.springframework.messaging=INFO logging.level.org.springframework.web.socket=INFO -spring.profiles.active=dev \ No newline at end of file +spring.profiles.active=dev diff --git a/walkguide-backend/demo/src/main/resources/openapi.yaml b/walkguide-backend/demo/src/main/resources/openapi.yaml index f2adc26..1dfcfac 100644 --- a/walkguide-backend/demo/src/main/resources/openapi.yaml +++ b/walkguide-backend/demo/src/main/resources/openapi.yaml @@ -273,6 +273,15 @@ paths: schema: { type: integer, format: int64 } responses: "200": { description: SOS acknowledged } + /guardian/sos/{id}/resolve: + put: + parameters: + - in: path + name: id + required: true + schema: { type: integer, format: int64 } + responses: + "200": { description: SOS handled and resolved } /guardian/ai-config: get: responses: @@ -291,6 +300,9 @@ paths: get: responses: "200": { description: Paired user shortcuts } + put: + responses: + "200": { description: Paired user shortcut updated } /guardian/geofence: get: responses: diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/DemoApplicationTests.java b/walkguide-backend/demo/src/test/java/com/walkguide/DemoApplicationTests.java index 8b3d779..acbe321 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/DemoApplicationTests.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/DemoApplicationTests.java @@ -8,10 +8,10 @@ import org.springframework.boot.test.mock.mockito.MockBean; @SpringBootTest(properties = { "spring.datasource.url=jdbc:postgresql://localhost:5432/walkguide_test", "spring.datasource.username=test", - "spring.datasource.password=test", + "spring.datasource.password=${TEST_DB_PASSWORD}", "spring.flyway.enabled=false", "spring.jpa.hibernate.ddl-auto=none", - "jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970" + "jwt.secret=${TEST_JWT_SECRET}" }) class DemoApplicationTests { diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/controller/GuardianControllerTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/controller/GuardianControllerTest.java index 614c14a..d3f73fd 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/controller/GuardianControllerTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/controller/GuardianControllerTest.java @@ -211,6 +211,24 @@ class GuardianControllerTest { } } + @Test + @DisplayName("PUT /api/v1/guardian/sos/{id}/resolve - harus tandai SOS ditangani") + void resolveSos_shouldReturn200() throws Exception { + try (MockedStatic sh = mockStatic(SecurityHelper.class)) { + sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L); + + SosEventResponse resp = new SosEventResponse(); + when(sosService.resolveSos(2L, 10L)).thenReturn(resp); + + mockMvc.perform(put("/api/v1/guardian/sos/10/resolve") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("SOS ditangani")); + + verify(sosService).resolveSos(2L, 10L); + } + } + // ===== AI CONFIG ===== @Test @@ -254,7 +272,7 @@ class GuardianControllerTest { void getVoiceCommands_shouldReturn200() throws Exception { try (MockedStatic sh = mockStatic(SecurityHelper.class)) { sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L); - when(voiceCommandService.getAll(2L)).thenReturn(List.of()); + when(voiceCommandService.getAllForGuardian(2L)).thenReturn(List.of()); mockMvc.perform(get("/api/v1/guardian/voice-commands")) .andExpect(status().isOk()) @@ -288,7 +306,7 @@ class GuardianControllerTest { void getShortcuts_shouldReturn200() throws Exception { try (MockedStatic sh = mockStatic(SecurityHelper.class)) { sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L); - when(hardwareShortcutService.getAll(2L)).thenReturn(List.of()); + when(hardwareShortcutService.getAllForGuardian(2L)).thenReturn(List.of()); mockMvc.perform(get("/api/v1/guardian/shortcuts")) .andExpect(status().isOk()) @@ -296,6 +314,27 @@ class GuardianControllerTest { } } + @Test + @DisplayName("PUT /api/v1/guardian/shortcuts - harus update shortcut user yang dipair") + void updateShortcut_shouldReturn200() throws Exception { + try (MockedStatic sh = mockStatic(SecurityHelper.class)) { + sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L); + + HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest(); + HardwareShortcutResponse resp = new HardwareShortcutResponse(); + when(hardwareShortcutService.updateByGuardian(eq(2L), any())).thenReturn(resp); + + mockMvc.perform(put("/api/v1/guardian/shortcuts") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Hardware shortcut diperbarui")); + + verify(hardwareShortcutService).updateByGuardian(eq(2L), any(HardwareShortcutUpdateRequest.class)); + } + } + // ===== GEOFENCE ===== @Test diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/integration/AuthIntegrationTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/integration/AuthIntegrationTest.java index 27e4e74..bc54aa1 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/integration/AuthIntegrationTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/integration/AuthIntegrationTest.java @@ -1,7 +1,5 @@ package com.walkguide.integration; -import org.junit.jupiter.api.Disabled; - import com.fasterxml.jackson.databind.JsonNode; import com.walkguide.dto.request.LoginRequest; import com.walkguide.dto.request.RefreshTokenRequest; @@ -25,8 +23,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. */ @DisplayName("Integration Test — Auth Flow (Testcontainers)") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -@Disabled("Requires Docker/Testcontainers") -class AuthIntegrationTest extends AbstractIntegrationTest { +class AuthIntegrationTest extends AbstractIntegrationTest { private static final String USER_EMAIL = "integ_user@walkguide.test"; private static final String USER_PASS = "password123"; @@ -294,4 +291,4 @@ class AuthIntegrationTest extends AbstractIntegrationTest { assertThat(result.getResponse().getStatus()) .isBetween(200, 399)); } -} \ No newline at end of file +} diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/integration/PairingIntegrationTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/integration/PairingIntegrationTest.java index 80eca57..eaba7d5 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/integration/PairingIntegrationTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/integration/PairingIntegrationTest.java @@ -1,7 +1,5 @@ package com.walkguide.integration; -import org.junit.jupiter.api.Disabled; - import com.fasterxml.jackson.databind.JsonNode; import com.walkguide.dto.request.InviteUserRequest; import com.walkguide.dto.request.PairingResponseRequest; @@ -26,8 +24,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. */ @DisplayName("Integration Test — Pairing Flow (Testcontainers)") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -@Disabled("Requires Docker/Testcontainers") -class PairingIntegrationTest extends AbstractIntegrationTest { +class PairingIntegrationTest extends AbstractIntegrationTest { private static final String USER_EMAIL = "pairing_user@walkguide.test"; private static final String USER_PASS = "userpass123"; @@ -286,4 +283,4 @@ class PairingIntegrationTest extends AbstractIntegrationTest { mockMvc.perform(get("/api/v1/shared/pairing/status")) .andExpect(status().isForbidden()); } -} \ No newline at end of file +} diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/integration/UserFeatureIntegrationTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/integration/UserFeatureIntegrationTest.java index f036ea8..3bc6627 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/integration/UserFeatureIntegrationTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/integration/UserFeatureIntegrationTest.java @@ -1,7 +1,5 @@ package com.walkguide.integration; -import org.junit.jupiter.api.Disabled; - import com.fasterxml.jackson.databind.JsonNode; import com.walkguide.dto.request.*; import org.junit.jupiter.api.*; @@ -28,8 +26,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. */ @DisplayName("Integration Test — User Core Features (Testcontainers)") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -@Disabled("Requires Docker/Testcontainers") -class UserFeatureIntegrationTest extends AbstractIntegrationTest { +class UserFeatureIntegrationTest extends AbstractIntegrationTest { private static final String USER_EMAIL = "feature_user@walkguide.test"; private static final String USER_PASS = "userpass123"; @@ -488,4 +485,4 @@ class UserFeatureIntegrationTest extends AbstractIntegrationTest { .header("Authorization", bearerToken(userToken))) .andExpect(status().isForbidden()); } -} \ No newline at end of file +} diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/service/HardwareShortcutServiceTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/service/HardwareShortcutServiceTest.java index 3ba3f8e..c6cf539 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/service/HardwareShortcutServiceTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/service/HardwareShortcutServiceTest.java @@ -1,11 +1,15 @@ package com.walkguide.service; -import com.walkguide.dto.request.HardwareShortcutUpdateRequest; -import com.walkguide.dto.response.HardwareShortcutResponse; -import com.walkguide.entity.HardwareShortcut; -import com.walkguide.enums.HardwareShortcutKey; -import com.walkguide.exception.ResourceNotFoundException; -import com.walkguide.repository.HardwareShortcutRepository; +import com.walkguide.dto.request.HardwareShortcutUpdateRequest; +import com.walkguide.dto.response.HardwareShortcutResponse; +import com.walkguide.entity.PairingRelation; +import com.walkguide.entity.HardwareShortcut; +import com.walkguide.entity.User; +import com.walkguide.enums.HardwareShortcutKey; +import com.walkguide.enums.PairingStatus; +import com.walkguide.exception.ResourceNotFoundException; +import com.walkguide.repository.HardwareShortcutRepository; +import com.walkguide.repository.PairingRelationRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,7 +19,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; +import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -25,8 +30,10 @@ import static org.mockito.Mockito.*; @DisplayName("HardwareShortcutService Unit Tests") class HardwareShortcutServiceTest { - @Mock - HardwareShortcutRepository hardwareShortcutRepository; + @Mock + HardwareShortcutRepository hardwareShortcutRepository; + @Mock + PairingRelationRepository pairingRelationRepository; @InjectMocks HardwareShortcutService hardwareShortcutService; @@ -173,11 +180,36 @@ class HardwareShortcutServiceTest { @Test @DisplayName("update - shortcutKey enum invalid harus throw IllegalArgumentException") - void update_invalidEnumKey_shouldThrow() { + void update_invalidEnumKey_shouldThrow() { HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest(); req.setShortcutKey("INVALID_KEY_XYZ"); - assertThatThrownBy(() -> hardwareShortcutService.update(10L, req)) - .isInstanceOf(IllegalArgumentException.class); - } -} \ No newline at end of file + assertThatThrownBy(() -> hardwareShortcutService.update(10L, req)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("updateByGuardian - harus update shortcut milik user yang dipair") + void updateByGuardian_shouldUpdatePairedUserShortcut() { + HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest(); + req.setShortcutKey("CALL_GUARDIAN"); + req.setButtonName("Power Double Tap"); + + User guardian = User.builder().id(1L).build(); + User user = User.builder().id(10L).build(); + PairingRelation pairing = PairingRelation.builder() + .guardian(guardian).user(user).status(PairingStatus.ACTIVE).build(); + + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(pairing)); + when(hardwareShortcutRepository.findByUserId(10L)) + .thenReturn(List.of(shortcutCallGuardian)); + when(hardwareShortcutRepository.save(any(HardwareShortcut.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + HardwareShortcutResponse result = hardwareShortcutService.updateByGuardian(1L, req); + + assertThat(result.getButtonName()).isEqualTo("Power Double Tap"); + verify(hardwareShortcutRepository).findByUserId(10L); + } +} diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/service/SosServiceTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/service/SosServiceTest.java index 1763963..7bf6329 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/service/SosServiceTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/service/SosServiceTest.java @@ -159,8 +159,10 @@ class SosServiceTest { @Test @DisplayName("acknowledgeSos - SOS ditemukan: harus ubah status ke ACKNOWLEDGED") void acknowledgeSos_sosFound_shouldChangeStatusToAcknowledged() { - when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos)); - when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos)); + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0)); when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) .thenReturn(Optional.empty()); @@ -179,8 +181,10 @@ class SosServiceTest { @Test @DisplayName("acknowledgeSos - ada pairing user: harus kirim FCM ke user bahwa guardian sudah respon") void acknowledgeSos_activePairingForUser_shouldNotifyUser() { - when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos)); - when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos)); + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0)); when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) .thenReturn(Optional.of(activePairing)); @@ -198,13 +202,35 @@ class SosServiceTest { @Test @DisplayName("acknowledgeSos - SOS tidak ditemukan: harus throw ResourceNotFoundException") - void acknowledgeSos_sosNotFound_shouldThrowException() { - when(sosEventRepository.findById(999L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> sosService.acknowledgeSos(1L, 999L)) - .isInstanceOf(ResourceNotFoundException.class) - .hasMessageContaining("SOS event tidak ditemukan"); - } + void acknowledgeSos_sosNotFound_shouldThrowException() { + when(sosEventRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> sosService.acknowledgeSos(1L, 999L)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("SOS event tidak ditemukan"); + } + + @Test + @DisplayName("resolveSos - SOS ditemukan: harus ubah status ke RESOLVED") + void resolveSos_sosFound_shouldChangeStatusToResolved() { + when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos)); + when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0)); + when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) + .thenReturn(Optional.empty()); + doNothing().when(activityLogService).createLog(any(), any(), any(), any()); + + SosEventResponse result = sosService.resolveSos(1L, 50L); + + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo("RESOLVED"); + ArgumentCaptor captor = ArgumentCaptor.forClass(SosEvent.class); + verify(sosEventRepository).save(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo(SosStatus.RESOLVED); + assertThat(captor.getValue().getAcknowledgedAt()).isNotNull(); + } // ===== getSosEvents TESTS ===== @@ -250,4 +276,4 @@ class SosServiceTest { assertThatThrownBy(() -> sosService.getSosEventsForGuardian(1L, PageRequest.of(0, 10))) .isInstanceOf(ResourceNotFoundException.class); } -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/benchmark/PHYSICAL_DEVICE_BENCHMARK_CHECKLIST.md b/walkguide-mobile/walkguide_app/benchmark/PHYSICAL_DEVICE_BENCHMARK_CHECKLIST.md new file mode 100644 index 0000000..ca0616f --- /dev/null +++ b/walkguide-mobile/walkguide_app/benchmark/PHYSICAL_DEVICE_BENCHMARK_CHECKLIST.md @@ -0,0 +1,16 @@ +# WalkGuide Flutter Physical Device Benchmark Checklist + +Use this file as the final evidence checklist. Do not replace these rows with emulator data. + +| Metric | Command / Tool | Required Evidence | +|---|---|---| +| Cold start | `flutter run --profile --trace-startup` | `timeToFirstFrameMicros`, screenshot/log | +| Frame jank | DevTools Performance in profile mode | 90%+ frames under 16 ms | +| Memory baseline | DevTools Memory after app launch | Heap MB screenshot | +| Memory leak check | Navigate all major screens 10x | Heap growth before/after | +| CPU during YOLO | DevTools CPU profiler while WalkGuide active | Flame graph screenshot | +| API latency | Dio latency logs | p95 latency table | +| APK size | `flutter build apk --release --analyze-size` | Size analysis artifact | +| Delta table | Mid-sprint vs final | Week 5 vs final comparison | + +Final benchmark must be captured on a physical Android phone in profile mode. diff --git a/walkguide-mobile/walkguide_app/lib/app/injection_container.dart b/walkguide-mobile/walkguide_app/lib/app/injection_container.dart index 0eb2cfa..d51cd1b 100644 --- a/walkguide-mobile/walkguide_app/lib/app/injection_container.dart +++ b/walkguide-mobile/walkguide_app/lib/app/injection_container.dart @@ -2,56 +2,82 @@ import 'package:get_it/get_it.dart'; import 'package:flutter/foundation.dart'; import '../core/constants/app_constants.dart'; +import '../core/ai/obstacle_alert_strategy.dart'; import '../core/ai/obstacle_analyzer.dart'; import '../core/ai/yolo_detector.dart'; import '../core/network/api_client.dart'; import '../core/services/haptic_service.dart'; import '../core/services/call_service.dart'; import '../core/services/fcm_service.dart'; +import '../core/services/hardware_shortcut_listener.dart'; import '../core/services/location_reporter_service.dart'; import '../core/services/offline_queue_service.dart'; import '../core/services/stt_service.dart'; import '../core/services/tts_service.dart'; import '../core/services/voice_command_handler.dart'; import '../core/services/websocket_service.dart'; +import '../core/storage/local_database.dart'; import '../core/storage/secure_storage.dart'; +import '../core/utils/init_guard.dart'; +import '../features/notifications/application/notification_cubit.dart'; +import '../features/notifications/data/repositories/notification_repository_impl.dart'; +import '../features/notifications/domain/repositories/notification_repository.dart'; +import '../features/sos/application/sos_cubit.dart'; +import '../features/sos/data/repositories/sos_repository_impl.dart'; +import '../features/sos/domain/repositories/sos_repository.dart'; +import '../features/walk_guide/application/walk_guide_cubit.dart'; +import '../features/walk_guide/data/repositories/walk_guide_repository_impl.dart'; +import '../features/walk_guide/domain/repositories/walk_guide_repository.dart'; final sl = GetIt.instance; Future initDependencies() async { sl.registerLazySingleton(() => SecureStorage()); + sl.registerLazySingleton(() => LocalDatabase()); sl.registerLazySingleton(() => ApiClient(sl())); sl.registerLazySingleton(() => TtsService()); sl.registerLazySingleton(() => SttService()); sl.registerLazySingleton(() => HapticService()); + sl.registerLazySingleton( + () => TtsWithHapticObstacleAlertStrategy(sl(), sl()), + ); sl.registerLazySingleton(() => ObstacleAnalyzer()); sl.registerLazySingleton(() => YoloDetector(sl())); - sl.registerLazySingleton(() => OfflineQueueService()); + sl.registerLazySingleton( + () => OfflineQueueService(sl()), + ); sl.registerLazySingleton(() => FcmService(sl())); sl.registerLazySingleton(() => WebSocketService(sl())); sl.registerLazySingleton(() => LocationReporterService(sl(), sl())); sl.registerLazySingleton(() => CallService(sl())); + sl.registerLazySingleton( + () => HardwareShortcutListener(sl()), + ); sl.registerLazySingleton( () => VoiceCommandHandler(sl(), sl()), ); + sl.registerLazySingleton( + () => WalkGuideRepositoryImpl(sl(), sl()), + ); + sl.registerFactory(() => WalkGuideCubit(sl())); + sl.registerLazySingleton(() => SosRepositoryImpl(sl())); + sl.registerFactory(() => SosCubit(sl())); + sl.registerLazySingleton( + () => NotificationRepositoryImpl(sl(), sl()), + ); + sl.registerFactory( + () => NotificationCubit(sl()), + ); final serverUrl = await AppConstants.getServerUrl(); if (serverUrl != null && serverUrl.isNotEmpty) { await sl().init(serverUrl); } - try { - await sl().init(); - } catch (e) { - debugPrint('TTS init skipped: $e'); - } + await ignoreInitFailure(() => sl().init(), label: 'TTS init'); await sl().init(); if (!kIsWeb) { - try { - await sl().init(); - } catch (e) { - debugPrint('STT init skipped: $e'); - } + await ignoreInitFailure(() => sl().init(), label: 'STT init'); } sl().loadDefaultCommands(); if (!kIsWeb) { diff --git a/walkguide-mobile/walkguide_app/lib/app/router.dart b/walkguide-mobile/walkguide_app/lib/app/router.dart index 2ad70af..5eada23 100644 --- a/walkguide-mobile/walkguide_app/lib/app/router.dart +++ b/walkguide-mobile/walkguide_app/lib/app/router.dart @@ -1,34 +1,42 @@ import 'package:go_router/go_router.dart'; +import '../app/injection_container.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 '../core/storage/secure_storage.dart'; +import '../features/activity_log/presentation/screens/activity_log_screen.dart' + as activity; +import '../features/ai_benchmark/presentation/screens/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' +import '../features/call/presentation/screens/call_screen.dart' as call; +import '../features/guardian_dashboard/presentation/screens/guardian_activity_log_screen.dart' as guardian_logs; -import '../features/guardian_dashboard/guardian_ai_config_screen.dart' +import '../features/guardian_dashboard/presentation/screens/guardian_ai_config_screen.dart' as guardian_ai; -import '../features/guardian_dashboard/guardian_map_screen.dart' +import '../features/guardian_dashboard/presentation/screens/guardian_map_screen.dart' as guardian_map; -import '../features/guardian_dashboard/guardian_send_notification_screen.dart' +import '../features/guardian_dashboard/presentation/screens/guardian_send_notification_screen.dart' as guardian_send; -import '../features/guardian_dashboard/guardian_settings_screen.dart' +import '../features/guardian_dashboard/presentation/screens/guardian_settings_screen.dart' as guardian_settings; -import '../features/guardian_dashboard/guardian_tools_screen.dart' +import '../features/guardian_dashboard/presentation/screens/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/pairing/pairing_screens.dart' as pairing; +import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart' + as nav; +import '../features/notifications/presentation/screens/notification_screen.dart' + as notifications; +import '../features/pairing/presentation/screens/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 '../features/settings/presentation/screens/user_settings_screen.dart' + as user_settings; +import '../features/sos/presentation/screens/sos_screen.dart' as sos; +import '../features/walk_guide/presentation/screens/walk_guide_screen.dart' + as walk_guide; import '../shared/widgets/app_shells.dart'; final GoRouter appRouter = GoRouter( @@ -36,6 +44,10 @@ final GoRouter appRouter = GoRouter( redirect: (context, state) async { final path = state.matchedLocation; final serverUrl = await AppConstants.getServerUrl(); + final isPublicRoute = path == '/server-connect' || + path == '/splash' || + path == '/login' || + path == '/register'; if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') { return '/server-connect'; @@ -45,6 +57,31 @@ final GoRouter appRouter = GoRouter( serverUrl.isNotEmpty) { return '/splash'; } + if (serverUrl == null || serverUrl.isEmpty) return null; + + final storage = sl(); + final token = await storage.getAccessToken(); + final role = await storage.getUserRole(); + + if ((token == null || token.isEmpty) && !isPublicRoute) { + return '/login'; + } + if (token != null && token.isNotEmpty) { + final home = role == 'ROLE_GUARDIAN' + ? '/guardian/dashboard' + : role == 'ROLE_USER' + ? '/user/walkguide' + : '/login'; + if (path == '/splash' || path == '/login' || path == '/register') { + return home; + } + if (path.startsWith('/guardian') && role != 'ROLE_GUARDIAN') { + return '/user/walkguide'; + } + if (path.startsWith('/user') && role != 'ROLE_USER') { + return '/guardian/dashboard'; + } + } return null; }, routes: [ diff --git a/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_alert_strategy.dart b/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_alert_strategy.dart new file mode 100644 index 0000000..702ec4d --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_alert_strategy.dart @@ -0,0 +1,57 @@ +import '../services/haptic_service.dart'; +import '../services/tts_service.dart'; +import 'obstacle_analyzer.dart'; + +abstract class ObstacleAlertStrategy { + Future alert(DetectionResult detection); +} + +class TtsOnlyObstacleAlertStrategy implements ObstacleAlertStrategy { + final TtsService _ttsService; + + const TtsOnlyObstacleAlertStrategy(this._ttsService); + + @override + Future alert(DetectionResult detection) { + return _ttsService.speakImmediate(detection.spokenId); + } +} + +class HapticOnlyObstacleAlertStrategy implements ObstacleAlertStrategy { + final HapticService _hapticService; + + const HapticOnlyObstacleAlertStrategy(this._hapticService); + + @override + Future alert(DetectionResult detection) { + return _vibrateByDistance(detection); + } + + Future _vibrateByDistance(DetectionResult detection) { + final distance = detection.estimatedDistance.toLowerCase(); + if (distance.startsWith('very close')) { + return _hapticService.obstacleVeryClose(); + } + if (distance.startsWith('close')) { + return _hapticService.obstacleClose(); + } + return _hapticService.obstacleMedium(); + } +} + +class TtsWithHapticObstacleAlertStrategy implements ObstacleAlertStrategy { + final TtsService _ttsService; + final HapticService _hapticService; + + const TtsWithHapticObstacleAlertStrategy( + this._ttsService, + this._hapticService, + ); + + @override + Future alert(DetectionResult detection) async { + final haptic = HapticOnlyObstacleAlertStrategy(_hapticService); + await haptic.alert(detection); + await _ttsService.speakImmediate(detection.spokenId); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/api_service.dart b/walkguide-mobile/walkguide_app/lib/core/api_service.dart index fdc22a4..723504b 100644 --- a/walkguide-mobile/walkguide_app/lib/core/api_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/api_service.dart @@ -1,19 +1,28 @@ import 'package:dio/dio.dart'; -class ApiService { - static const baseUrl = String.fromEnvironment( - 'WALKGUIDE_API_BASE_URL', - defaultValue: 'http://202.46.28.160:8080/api/v1', - ); +import 'constants/app_constants.dart'; - final Dio _dio = Dio(BaseOptions( - baseUrl: baseUrl, - connectTimeout: const Duration(seconds: 5), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - )); +@Deprecated('Use ApiClient for authenticated requests. Kept for legacy callers.') +class ApiService { + ApiService._(String baseUrl) + : _dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 5), + headers: const { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + )); + + final Dio _dio; + + static Future create() async { + final serverUrl = await AppConstants.getServerUrl(); + if (serverUrl == null || serverUrl.isEmpty) { + throw StateError('WalkGuide server URL has not been configured.'); + } + return ApiService._(AppConstants.buildApiUrl(serverUrl)); + } Future post(String path, Map data) async { return await _dio.post(path, data: data); diff --git a/walkguide-mobile/walkguide_app/lib/core/i18n/app_strings.dart b/walkguide-mobile/walkguide_app/lib/core/i18n/app_strings.dart new file mode 100644 index 0000000..d1bea0f --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/i18n/app_strings.dart @@ -0,0 +1,31 @@ +class AppStrings { + final String localeCode; + + const AppStrings(this.localeCode); + + static const supportedLocales = ['id-ID', 'en-US']; + + String get walkGuideStarted => _pick( + id: 'WalkGuide dimulai', + en: 'WalkGuide started', + ); + + String get walkGuideStopped => _pick( + id: 'WalkGuide dihentikan', + en: 'WalkGuide stopped', + ); + + String get sosSent => _pick( + id: 'SOS terkirim. Guardian sudah diberi tahu.', + en: 'SOS sent. Your guardian has been alerted.', + ); + + String get notificationsOpened => _pick( + id: 'Notifikasi dibuka', + en: 'Notifications opened', + ); + + String _pick({required String id, required String en}) { + return localeCode == 'en-US' ? en : id; + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/services/hardware_shortcut_listener.dart b/walkguide-mobile/walkguide_app/lib/core/services/hardware_shortcut_listener.dart new file mode 100644 index 0000000..8549edf --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/services/hardware_shortcut_listener.dart @@ -0,0 +1,131 @@ +import 'package:flutter/services.dart'; + +import '../network/api_client.dart'; + +enum HardwareShortcutAction { + callGuardian, + startWalkguide, + stopWalkguide, + sendSos, + openNotification, +} + +class HardwareShortcutBinding { + final HardwareShortcutAction action; + final int buttonCode; + final String buttonName; + final bool enabled; + + const HardwareShortcutBinding({ + required this.action, + required this.buttonCode, + required this.buttonName, + required this.enabled, + }); +} + +class HardwareShortcutListener { + final ApiClient _apiClient; + final Map _bindings = {}; + + bool _listening = false; + void Function(HardwareShortcutAction action)? _onAction; + void Function(int buttonCode, String buttonName)? _captureCallback; + + HardwareShortcutListener(this._apiClient); + + Future startListening({ + required void Function(HardwareShortcutAction action) onAction, + }) async { + _onAction = onAction; + if (!_listening) { + HardwareKeyboard.instance.addHandler(_handleKeyEvent); + _listening = true; + } + await loadFromBackend(); + } + + void stopListening() { + if (!_listening) return; + HardwareKeyboard.instance.removeHandler(_handleKeyEvent); + _listening = false; + } + + Future loadFromBackend() async { + final response = await _apiClient.dio.get('/user/shortcuts'); + final body = response.data; + final data = body is Map ? body['data'] : body; + if (data is! List) return; + + _bindings + ..clear() + ..addEntries( + data + .whereType() + .map((item) => _bindingFromJson(Map.from(item))) + .whereType() + .map((binding) => MapEntry(binding.buttonCode, binding)), + ); + } + + void captureNextButton(void Function(int buttonCode, String buttonName) onCapture) { + _captureCallback = onCapture; + } + + bool _handleKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent) return false; + final code = _buttonCode(event.logicalKey); + final name = event.logicalKey.keyLabel.isNotEmpty + ? event.logicalKey.keyLabel + : event.logicalKey.debugName ?? 'Button $code'; + + final capture = _captureCallback; + if (capture != null) { + _captureCallback = null; + capture(code, name); + return true; + } + + final binding = _bindings[code]; + if (binding == null || !binding.enabled) return false; + _onAction?.call(binding.action); + return true; + } + + int _buttonCode(LogicalKeyboardKey key) { + if (key == LogicalKeyboardKey.audioVolumeUp) return 24; + if (key == LogicalKeyboardKey.audioVolumeDown) return 25; + return key.keyId & 0xFFFFFFFF; + } +} + +HardwareShortcutBinding? _bindingFromJson(Map item) { + final action = _actionFromBackend(item['shortcutKey']?.toString()); + final rawCode = item['buttonCode']; + final enabled = item['enabled'] != false; + final code = rawCode is int ? rawCode : int.tryParse(rawCode?.toString() ?? ''); + if (action == null || code == null || code <= 0) return null; + return HardwareShortcutBinding( + action: action, + buttonCode: code, + buttonName: item['buttonName']?.toString() ?? 'Button $code', + enabled: enabled, + ); +} + +HardwareShortcutAction? _actionFromBackend(String? key) { + switch (key) { + case 'CALL_GUARDIAN': + return HardwareShortcutAction.callGuardian; + case 'START_WALKGUIDE': + return HardwareShortcutAction.startWalkguide; + case 'STOP_WALKGUIDE': + return HardwareShortcutAction.stopWalkguide; + case 'SEND_SOS': + return HardwareShortcutAction.sendSos; + case 'OPEN_NOTIFICATION': + return HardwareShortcutAction.openNotification; + default: + return null; + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/services/offline_queue_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/offline_queue_service.dart index 0c21419..80473d4 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/offline_queue_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/offline_queue_service.dart @@ -1,8 +1,5 @@ -import 'dart:convert'; - -import 'package:shared_preferences/shared_preferences.dart'; - import '../network/api_client.dart'; +import '../storage/local_database.dart'; class OfflineRequest { final String method; @@ -33,26 +30,35 @@ class OfflineRequest { } class OfflineQueueService { - static const _key = 'offline_request_queue'; + final LocalDatabase _database; + + OfflineQueueService(this._database); Future enqueue(OfflineRequest request) async { - final prefs = await SharedPreferences.getInstance(); - final queue = await readAll(); - queue.add(request); - await prefs.setString(_key, jsonEncode(queue.map((e) => e.toJson()).toList())); + await _database.offlineRequests.insert(OfflineRequestRecord( + method: request.method, + path: request.path, + body: request.body, + createdAt: request.createdAt, + )); } Future> readAll() async { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_key); - if (raw == null || raw.isEmpty) return []; - final decoded = jsonDecode(raw) as List; - return decoded.map((e) => OfflineRequest.fromJson(Map.from(e as Map))).toList(); + final records = await _database.offlineRequests.getAll(); + return records + .map( + (record) => OfflineRequest( + method: record.method, + path: record.path, + body: record.body, + createdAt: record.createdAt, + ), + ) + .toList(); } Future clear() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_key); + await _database.offlineRequests.clear(); } Future syncPending(ApiClient apiClient) async { @@ -79,11 +85,21 @@ class OfflineQueueService { } } - final prefs = await SharedPreferences.getInstance(); if (remaining.isEmpty) { - await prefs.remove(_key); + await clear(); } else { - await prefs.setString(_key, jsonEncode(remaining.map((e) => e.toJson()).toList())); + await _database.offlineRequests.replaceAll( + remaining + .map( + (request) => OfflineRequestRecord( + method: request.method, + path: request.path, + body: request.body, + createdAt: request.createdAt, + ), + ) + .toList(), + ); } return synced; } diff --git a/walkguide-mobile/walkguide_app/lib/core/storage/local_cache_store_native.dart b/walkguide-mobile/walkguide_app/lib/core/storage/local_cache_store_native.dart new file mode 100644 index 0000000..dfabf6b --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/storage/local_cache_store_native.dart @@ -0,0 +1,51 @@ +import 'package:path_provider/path_provider.dart'; +import 'package:sqlite3/sqlite3.dart'; + +class LocalCacheStore { + LocalCacheStore._(); + + static final LocalCacheStore instance = LocalCacheStore._(); + static const _table = 'walkguide_cache'; + + Database? _database; + + Future get(String key) async { + final db = await _open(); + final result = db.select( + 'SELECT value FROM $_table WHERE cache_key = ? LIMIT 1', + [key], + ); + if (result.isEmpty) return null; + return result.first['value'] as String?; + } + + Future set(String key, String value) async { + final db = await _open(); + db.execute( + 'INSERT INTO $_table(cache_key, value, updated_at) VALUES (?, ?, ?) ' + 'ON CONFLICT(cache_key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', + [key, value, DateTime.now().toIso8601String()], + ); + } + + Future remove(String key) async { + final db = await _open(); + db.execute('DELETE FROM $_table WHERE cache_key = ?', [key]); + } + + Future _open() async { + final existing = _database; + if (existing != null) return existing; + final directory = await getApplicationDocumentsDirectory(); + final db = sqlite3.open('${directory.path}/walkguide_cache.sqlite'); + db.execute(''' + CREATE TABLE IF NOT EXISTS $_table ( + cache_key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + _database = db; + return db; + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/storage/local_cache_store_web.dart b/walkguide-mobile/walkguide_app/lib/core/storage/local_cache_store_web.dart new file mode 100644 index 0000000..ff2f550 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/storage/local_cache_store_web.dart @@ -0,0 +1,22 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class LocalCacheStore { + LocalCacheStore._(); + + static final LocalCacheStore instance = LocalCacheStore._(); + + Future get(String key) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(key); + } + + Future set(String key, String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(key, value); + } + + Future remove(String key) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(key); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/storage/local_database.dart b/walkguide-mobile/walkguide_app/lib/core/storage/local_database.dart new file mode 100644 index 0000000..68644a5 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/storage/local_database.dart @@ -0,0 +1,239 @@ +import 'dart:convert'; + +import 'local_cache_store_web.dart' + if (dart.library.io) 'local_cache_store_native.dart'; + +class CachedActivityLog { + final int? id; + final int? userId; + final String logType; + final String description; + final String? metadata; + final DateTime createdAt; + final bool synced; + + const CachedActivityLog({ + this.id, + this.userId, + required this.logType, + required this.description, + this.metadata, + required this.createdAt, + this.synced = false, + }); + + Map toJson() => { + 'id': id, + 'userId': userId, + 'logType': logType, + 'description': description, + 'metadata': metadata, + 'createdAt': createdAt.toIso8601String(), + 'synced': synced, + }; + + factory CachedActivityLog.fromJson(Map json) { + return CachedActivityLog( + id: json['id'] as int?, + userId: json['userId'] as int?, + logType: json['logType'] as String, + description: json['description'] as String, + metadata: json['metadata'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + synced: json['synced'] == true, + ); + } +} + +class CachedObstacleLog { + final String label; + final String direction; + final String estimatedDistance; + final DateTime createdAt; + final bool synced; + + const CachedObstacleLog({ + required this.label, + required this.direction, + required this.estimatedDistance, + required this.createdAt, + this.synced = false, + }); + + Map toJson() => { + 'label': label, + 'direction': direction, + 'estimatedDistance': estimatedDistance, + 'createdAt': createdAt.toIso8601String(), + 'synced': synced, + }; + + factory CachedObstacleLog.fromJson(Map json) { + return CachedObstacleLog( + label: json['label'] as String, + direction: json['direction'] as String, + estimatedDistance: json['estimatedDistance'] as String, + createdAt: DateTime.parse(json['createdAt'] as String), + synced: json['synced'] == true, + ); + } +} + +class CachedNotification { + final int? id; + final String notificationType; + final String? content; + final String? voiceNoteUrl; + final bool isRead; + final DateTime createdAt; + + const CachedNotification({ + this.id, + required this.notificationType, + this.content, + this.voiceNoteUrl, + required this.isRead, + required this.createdAt, + }); + + Map toJson() => { + 'id': id, + 'notificationType': notificationType, + 'content': content, + 'voiceNoteUrl': voiceNoteUrl, + 'isRead': isRead, + 'createdAt': createdAt.toIso8601String(), + }; + + factory CachedNotification.fromJson(Map json) { + return CachedNotification( + id: json['id'] as int?, + notificationType: json['notificationType'] as String, + content: json['content'] as String?, + voiceNoteUrl: json['voiceNoteUrl'] as String?, + isRead: json['isRead'] == true, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } +} + +class LocalDatabase { + final activityLogs = ActivityLogDao(); + final obstacleLogs = ObstacleLogDao(); + final notifications = NotificationDao(); + final offlineRequests = OfflineRequestDao(); +} + +abstract class _JsonListDao { + String get storageKey; + T fromJson(Map json); + Map toJson(T item); + + Future> getAll() async { + final raw = await LocalCacheStore.instance.get(storageKey); + if (raw == null || raw.isEmpty) return []; + final decoded = jsonDecode(raw) as List; + return decoded + .map((item) => fromJson(Map.from(item as Map))) + .toList(); + } + + Future replaceAll(List items) async { + await LocalCacheStore.instance.set( + storageKey, + jsonEncode(items.map(toJson).toList()), + ); + } + + Future insert(T item) async { + final items = await getAll(); + items.add(item); + await replaceAll(items); + } + + Future clear() async { + await LocalCacheStore.instance.remove(storageKey); + } +} + +class ActivityLogDao extends _JsonListDao { + @override + String get storageKey => 'cached_activity_logs'; + + @override + CachedActivityLog fromJson(Map json) { + return CachedActivityLog.fromJson(json); + } + + @override + Map toJson(CachedActivityLog item) => item.toJson(); +} + +class ObstacleLogDao extends _JsonListDao { + @override + String get storageKey => 'cached_obstacle_logs'; + + @override + CachedObstacleLog fromJson(Map json) { + return CachedObstacleLog.fromJson(json); + } + + @override + Map toJson(CachedObstacleLog item) => item.toJson(); +} + +class NotificationDao extends _JsonListDao { + @override + String get storageKey => 'cached_notifications'; + + @override + CachedNotification fromJson(Map json) { + return CachedNotification.fromJson(json); + } + + @override + Map toJson(CachedNotification item) => item.toJson(); +} + +class OfflineRequestRecord { + final String method; + final String path; + final Map body; + final DateTime createdAt; + + const OfflineRequestRecord({ + required this.method, + required this.path, + required this.body, + required this.createdAt, + }); + + Map toJson() => { + 'method': method, + 'path': path, + 'body': body, + 'createdAt': createdAt.toIso8601String(), + }; + + factory OfflineRequestRecord.fromJson(Map json) { + return OfflineRequestRecord( + method: json['method'] as String, + path: json['path'] as String, + body: Map.from(json['body'] as Map), + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } +} + +class OfflineRequestDao extends _JsonListDao { + @override + String get storageKey => 'offline_request_queue'; + + @override + OfflineRequestRecord fromJson(Map json) { + return OfflineRequestRecord.fromJson(json); + } + + @override + Map toJson(OfflineRequestRecord item) => item.toJson(); +} diff --git a/walkguide-mobile/walkguide_app/lib/core/utils/init_guard.dart b/walkguide-mobile/walkguide_app/lib/core/utils/init_guard.dart new file mode 100644 index 0000000..37c3461 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/utils/init_guard.dart @@ -0,0 +1,13 @@ +import 'package:flutter/foundation.dart'; + +Future ignoreInitFailure( + Future Function() action, { + required String label, +}) async { + try { + return await action(); + } catch (error) { + debugPrint('$label skipped: $error'); + return null; + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/utils/operation_guard.dart b/walkguide-mobile/walkguide_app/lib/core/utils/operation_guard.dart new file mode 100644 index 0000000..4608de4 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/utils/operation_guard.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +Future guarded( + Future Function() action, { + void Function(Object error)? onError, + void Function()? onTimeout, +}) async { + try { + return await action(); + } on TimeoutException catch (error) { + onTimeout?.call(); + onError?.call(error); + return null; + } catch (error) { + onError?.call(error); + return null; + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/activity_log/presentation/screens/activity_log_screen.dart b/walkguide-mobile/walkguide_app/lib/features/activity_log/presentation/screens/activity_log_screen.dart new file mode 100644 index 0000000..127fef7 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/activity_log/presentation/screens/activity_log_screen.dart @@ -0,0 +1 @@ +export '../../activity_log_screen.dart'; 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 index 4787706..c312b42 100644 --- 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 @@ -10,6 +10,7 @@ import '../../app/injection_container.dart'; import '../../core/ai/detection_export.dart'; import '../../core/constants/app_constants.dart'; import '../../core/services/tts_service.dart'; +import '../../core/utils/operation_guard.dart'; import '../../shared/widgets/feature_page.dart'; class AiBenchmarkScreen extends StatefulWidget { @@ -79,11 +80,11 @@ class _AiBenchmarkScreenState extends State { notifWatch.stop(); final ttsWatch = Stopwatch()..start(); - try { - await sl() + await guarded( + () => sl() .speakImmediate(notificationText) - .timeout(const Duration(seconds: 3)); - } catch (_) {} + .timeout(const Duration(seconds: 3)), + ); ttsWatch.stop(); final run = { @@ -113,23 +114,27 @@ class _AiBenchmarkScreenState extends State { Future _measureCapture() async { final watch = Stopwatch()..start(); CameraController? controller; - try { + await guarded( + () async { final cameras = await availableCameras().timeout(const Duration(seconds: 3)); if (cameras.isNotEmpty) { - controller = CameraController( + final activeController = CameraController( cameras.first, ResolutionPreset.low, enableAudio: false, ); - await controller.initialize().timeout(const Duration(seconds: 5)); - await controller.takePicture().timeout(const Duration(seconds: 5)); + controller = activeController; + await activeController.initialize().timeout(const Duration(seconds: 5)); + await activeController.takePicture().timeout(const Duration(seconds: 5)); } - } catch (_) { + }, + onError: (_) {}, + ); + if (controller == null) { await Future.delayed(const Duration(milliseconds: 16)); - } finally { - await controller?.dispose(); } + await controller?.dispose(); watch.stop(); return watch.elapsedMilliseconds; } @@ -273,7 +278,8 @@ class _StatusBox extends StatelessWidget { } Future> _discoverTfliteModels() async { - try { + return await guarded>( + () async { final manifestRaw = await rootBundle.loadString('AssetManifest.json'); final manifest = jsonDecode(manifestRaw) as Map; final models = manifest.keys @@ -282,9 +288,9 @@ Future> _discoverTfliteModels() async { .toList() ..sort(); return models; - } catch (_) { - return const []; - } + }, + ) ?? + const []; } String _two(int value) => value.toString().padLeft(2, '0'); diff --git a/walkguide-mobile/walkguide_app/lib/features/ai_benchmark/presentation/screens/ai_benchmark_screen.dart b/walkguide-mobile/walkguide_app/lib/features/ai_benchmark/presentation/screens/ai_benchmark_screen.dart new file mode 100644 index 0000000..b3d936d --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/ai_benchmark/presentation/screens/ai_benchmark_screen.dart @@ -0,0 +1 @@ +export '../../ai_benchmark_screen.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/data/auth_remote_data_source.dart b/walkguide-mobile/walkguide_app/lib/features/auth/data/auth_remote_data_source.dart index ff055cd..7c033fb 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/data/auth_remote_data_source.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/data/auth_remote_data_source.dart @@ -1,5 +1,5 @@ import 'package:dio/dio.dart'; -import '../../../../core/api_service.dart'; +import '../../../../core/network/api_client.dart'; import 'auth_model.dart'; abstract class AuthRemoteDataSource { @@ -7,14 +7,14 @@ abstract class AuthRemoteDataSource { } class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { - final ApiService apiService; + final ApiClient apiClient; - AuthRemoteDataSourceImpl(this.apiService); + AuthRemoteDataSourceImpl(this.apiClient); @override Future login(String email, String password) async { try { - final response = await apiService.post('/auth/login', { + final response = await apiClient.dio.post('/auth/login', data: { 'email': email, 'password': password, }); @@ -30,4 +30,4 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource { throw Exception(e.response?.data['message'] ?? 'Terjadi kesalahan jaringan'); } } -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/lib/features/call/presentation/screens/call_screen.dart b/walkguide-mobile/walkguide_app/lib/features/call/presentation/screens/call_screen.dart new file mode 100644 index 0000000..4957d8c --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/call/presentation/screens/call_screen.dart @@ -0,0 +1 @@ +export '../../call_screen.dart'; 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 ce64495..2c3e9cd 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 @@ -6,9 +6,10 @@ import 'package:flutter/material.dart'; 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'; +import '../../../app/injection_container.dart'; +import '../../../core/errors/friendly_error.dart'; +import '../../../core/network/api_client.dart'; +import '../../../core/utils/operation_guard.dart'; Dio get _api => sl().dio; @@ -46,9 +47,10 @@ class _GuardianAiConfigScreenState extends State { _error = null; _needsPairing = false; }); - try { - final paired = await _hasActivePairing(); - if (!paired) { + await guarded( + () async { + final paired = await _hasActivePairing(); + if (!paired) { setState(() { _needsPairing = true; _loading = false; @@ -69,26 +71,24 @@ class _GuardianAiConfigScreenState extends State { _alertDistanceMedium = (data['alertDistanceMedium'] as num?)?.toDouble() ?? 3.0; _maxInferenceFps = (data['maxInferenceFps'] as num?)?.toInt() ?? 5; - _enabledLabels = data['enabledLabels']?.toString() ?? 'ALL'; - }); - } - } on DioException catch (e) { - setState(() { - _error = - friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.'); - }); - } catch (e) { - setState( - () => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.'); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - Future _save() async { - setState(() => _saving = true); - try { - await _api.put('/guardian/ai-config', data: { + _enabledLabels = data['enabledLabels']?.toString() ?? 'ALL'; + }); + } + }, + onError: (error) => setState(() { + _error = error is DioException + ? friendlyDioMessage(error, fallback: 'Gagal memuat konfigurasi AI.') + : 'Gagal memuat konfigurasi AI. Coba refresh lagi.'; + }), + ); + if (mounted) setState(() => _loading = false); + } + + Future _save() async { + setState(() => _saving = true); + await guarded( + () async { + await _api.put('/guardian/ai-config', data: { 'confidenceThreshold': _confidenceThreshold, 'alertDistanceClose': _alertDistanceClose, 'alertDistanceMedium': _alertDistanceMedium, @@ -100,43 +100,39 @@ class _GuardianAiConfigScreenState extends State { const SnackBar( content: Text('Konfigurasi AI berhasil disimpan'), backgroundColor: Color(0xFF16A34A), - ), - ); - } - } on DioException catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(friendlyDioMessage(e, - fallback: 'Gagal menyimpan konfigurasi.')), - backgroundColor: const Color(0xFFDC2626), - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Gagal menyimpan konfigurasi. Coba lagi.'), - backgroundColor: Color(0xFFDC2626), - ), - ); - } - } finally { - if (mounted) setState(() => _saving = false); - } - } - - 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; - } + ), + ); + } + }, + onError: (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error is DioException + ? friendlyDioMessage(error, + fallback: 'Gagal menyimpan konfigurasi.') + : 'Gagal menyimpan konfigurasi. Coba lagi.'), + backgroundColor: const Color(0xFFDC2626), + ), + ); + }, + ); + if (mounted) setState(() => _saving = false); + } + + Future _hasActivePairing() async { + return await guarded( + () 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; + }, + ) ?? + false; + } @override Widget build(BuildContext context) { 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 index 0719855..fbec03c 100644 --- 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 @@ -1,4 +1,9 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; @@ -15,29 +20,54 @@ class GuardianSendNotifScreen extends StatefulWidget { class _GuardianSendNotifScreenState extends State { final _message = TextEditingController(); + final _recorder = AudioRecorder(); bool _loading = false; + bool _recording = false; + bool _voiceMode = false; + String? _voicePath; + DateTime? _recordStart; + int _voiceDuration = 0; @override void dispose() { _message.dispose(); + _recorder.dispose(); super.dispose(); } Future _send() async { final message = _message.text.trim(); - if (message.isEmpty) { + if (!_voiceMode && message.isEmpty) { _snack('Tulis pesan dulu.'); return; } + if (_voiceMode && _voicePath == null) { + _snack('Rekam voice note dulu.'); + return; + } setState(() => _loading = true); await runFriendlyAction( () async { + final data = _voiceMode + ? { + 'notifType': 'VOICE_NOTE', + 'content': await _voiceAsDataUrl(), + 'voiceNoteUrl': 'inline-audio', + 'voiceNoteDuration': _voiceDuration, + } + : { + 'notifType': 'TEXT', + 'content': message, + }; await sl().dio.post('/guardian/notifications/send', data: { - 'notifType': 'TEXT', - 'content': message, - }).timeout(const Duration(seconds: 8)); + ...data, + }).timeout(const Duration(seconds: 12)); _message.clear(); - _snack('Notifikasi terkirim ke User.'); + _voicePath = null; + _voiceDuration = 0; + _snack(_voiceMode + ? 'Voice message terkirim ke User.' + : 'Notifikasi terkirim ke User.'); }, onError: _snack, fallback: 'Gagal mengirim notifikasi. Coba lagi.', @@ -45,6 +75,50 @@ class _GuardianSendNotifScreenState extends State { if (mounted) setState(() => _loading = false); } + Future _toggleRecording() async { + if (_recording) { + final path = await _recorder.stop(); + final duration = _recordStart == null + ? 0 + : DateTime.now().difference(_recordStart!).inSeconds; + if (!mounted) return; + setState(() { + _recording = false; + _voicePath = path; + _voiceDuration = duration.clamp(1, 600).toInt(); + _recordStart = null; + }); + return; + } + + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + _snack('Izin mikrofon dibutuhkan untuk rekam voice message.'); + return; + } + final dir = await getTemporaryDirectory(); + final path = + '${dir.path}/walkguide_voice_${DateTime.now().millisecondsSinceEpoch}.m4a'; + await _recorder.start( + const RecordConfig(encoder: AudioEncoder.aacLc), + path: path, + ); + if (!mounted) return; + setState(() { + _recording = true; + _recordStart = DateTime.now(); + _voicePath = null; + _voiceDuration = 0; + }); + } + + Future _voiceAsDataUrl() async { + final path = _voicePath; + if (path == null) return ''; + final bytes = await File(path).readAsBytes(); + return 'data:audio/mp4;base64,${base64Encode(bytes)}'; + } + void _snack(String message) { if (!mounted) return; ScaffoldMessenger.of(context) @@ -75,10 +149,29 @@ class _GuardianSendNotifScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + icon: Icon(Icons.message_outlined), + label: Text('Text'), + ), + ButtonSegment( + value: true, + icon: Icon(Icons.mic_none_outlined), + label: Text('Voice'), + ), + ], + selected: {_voiceMode}, + onSelectionChanged: _loading || _recording + ? null + : (value) => setState(() => _voiceMode = value.first), + ), + const SizedBox(height: 14), TextField( controller: _message, - minLines: 5, - maxLines: 8, + minLines: _voiceMode ? 2 : 5, + maxLines: _voiceMode ? 3 : 8, decoration: const InputDecoration( labelText: 'Message', hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.', @@ -86,6 +179,68 @@ class _GuardianSendNotifScreenState extends State { alignLabelWithHint: true, ), ), + if (_voiceMode) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + CircleAvatar( + backgroundColor: _recording + ? const Color(0xFFFEE2E2) + : const Color(0xFFEFF6FF), + child: Icon( + _recording ? Icons.graphic_eq : Icons.mic, + color: _recording + ? const Color(0xFFDC2626) + : const Color(0xFF2563EB), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _recording + ? 'Recording... tap stop when done' + : _voicePath == null + ? 'No voice note recorded' + : 'Voice note ready', + style: const TextStyle( + fontWeight: FontWeight.w800), + ), + Text( + _recording + ? 'Speak clearly near the microphone' + : _voicePath == null + ? 'Record a short message for User' + : '${_voiceDuration}s audio attached', + style: const TextStyle( + color: Color(0xFF64748B), fontSize: 12), + ), + ], + ), + ), + FilledButton.icon( + onPressed: _loading ? null : _toggleRecording, + icon: Icon(_recording ? Icons.stop : Icons.fiber_manual_record), + label: Text(_recording ? 'Stop' : 'Record'), + style: FilledButton.styleFrom( + backgroundColor: _recording + ? const Color(0xFFDC2626) + : const Color(0xFF2563EB), + ), + ), + ], + ), + ), + ], const SizedBox(height: 14), FilledButton.icon( onPressed: _loading ? null : _send, @@ -96,7 +251,11 @@ class _GuardianSendNotifScreenState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.send), - label: Text(_loading ? 'Sending...' : 'Send Message'), + label: Text(_loading + ? 'Sending...' + : _voiceMode + ? 'Send Voice Message' + : 'Send Message'), ), ], ), 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 index 9734dd1..2828a8a 100644 --- 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 @@ -63,6 +63,8 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> { bool _loading = true; String? _error; List> _items = const []; + bool get _isVoiceCommands => widget.endpoint.contains('voice-commands'); + bool get _isShortcuts => widget.endpoint.contains('shortcuts'); @override void initState() { @@ -131,28 +133,188 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> { itemBuilder: (_, index) => _EndpointCard( icon: widget.icon, item: _items[index], + editable: _isVoiceCommands || _isShortcuts, + onEdit: () => _editItem(_items[index]), ), ), ), ); } + + Future _editItem(Map item) async { + if (_isVoiceCommands) { + await _editVoiceCommand(item); + } else if (_isShortcuts) { + await _editShortcut(item); + } + } + + Future _editVoiceCommand(Map item) async { + final phrase = TextEditingController( + text: item['triggerPhrase']?.toString() ?? '', + ); + var enabled = item['enabled'] != false; + final saved = await showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(_labelFromKey(item['commandKey']?.toString() ?? '') ?? + 'Voice Command'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: phrase, + decoration: const InputDecoration( + labelText: 'Trigger phrase', + prefixIcon: Icon(Icons.record_voice_over_outlined), + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: enabled, + onChanged: (value) => setDialogState(() => enabled = value), + title: const Text('Enabled'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Save')), + ], + ), + ), + ); + if (saved != true) return; + await _submitUpdate({ + 'commandKey': item['commandKey'], + 'triggerPhrase': phrase.text.trim(), + 'enabled': enabled, + }); + } + + Future _editShortcut(Map item) async { + final buttonName = TextEditingController( + text: item['buttonName']?.toString() ?? '', + ); + final buttonCode = TextEditingController( + text: item['buttonCode']?.toString() ?? '', + ); + var enabled = item['enabled'] != false; + final saved = await showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(_labelFromKey(item['shortcutKey']?.toString() ?? '') ?? + 'Shortcut'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: buttonName, + decoration: const InputDecoration( + labelText: 'Button name', + prefixIcon: Icon(Icons.touch_app_outlined), + ), + ), + const SizedBox(height: 10), + TextField( + controller: buttonCode, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Android key code', + prefixIcon: Icon(Icons.numbers_outlined), + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: enabled, + onChanged: (value) => setDialogState(() => enabled = value), + title: const Text('Enabled'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Save')), + ], + ), + ), + ); + if (saved != true) return; + await _submitUpdate({ + 'shortcutKey': item['shortcutKey'], + 'buttonName': buttonName.text.trim(), + 'buttonCode': int.tryParse(buttonCode.text.trim()), + 'enabled': enabled, + }); + } + + Future _submitUpdate(Map payload) async { + await runFriendlyAction( + () async { + await sl() + .dio + .put(widget.endpoint, data: payload) + .timeout(const Duration(seconds: 8)); + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Konfigurasi berhasil disimpan.')), + ); + }, + onError: (message) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + }, + fallback: 'Konfigurasi belum bisa disimpan.', + ); + } } class _EndpointCard extends StatelessWidget { final IconData icon; final Map item; + final bool editable; + final VoidCallback? onEdit; - const _EndpointCard({required this.icon, required this.item}); + const _EndpointCard({ + required this.icon, + required this.item, + this.editable = false, + this.onEdit, + }); @override Widget build(BuildContext context) { - final title = _firstText(item, ['name', 'command', 'label', 'type']) ?? + final title = _labelFromKey( + _firstText(item, ['commandKey', 'shortcutKey', 'name', 'command']) ?? + '', + ) ?? 'Item #${item['id'] ?? '-'}'; final subtitle = _firstText( item, - ['description', 'action', 'shortcut', 'status', 'createdAt'], + [ + 'triggerPhrase', + 'buttonName', + 'description', + 'action', + 'shortcut', + 'status', + 'createdAt' + ], ) ?? 'Data aktif'; + final enabled = item['enabled'] != false; return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( @@ -181,15 +343,65 @@ class _EndpointCard extends StatelessWidget { const SizedBox(height: 3), Text(subtitle, style: const TextStyle(color: Color(0xFF64748B))), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _SmallPill( + label: enabled ? 'Enabled' : 'Disabled', + color: enabled + ? const Color(0xFF16A34A) + : const Color(0xFF94A3B8), + ), + if (item['buttonCode'] != null) + _SmallPill( + label: 'Key ${item['buttonCode']}', + color: const Color(0xFF2563EB), + ), + ], + ), ], ), ), + if (editable) + IconButton( + onPressed: onEdit, + icon: const Icon(Icons.edit_outlined), + tooltip: 'Edit', + ), ], ), ); } } +class _SmallPill extends StatelessWidget { + final String label; + final Color color; + + const _SmallPill({required this.label, required this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + String? _firstText(Map item, List keys) { for (final key in keys) { final value = item[key]?.toString().trim(); @@ -197,3 +409,13 @@ String? _firstText(Map item, List keys) { } return null; } + +String? _labelFromKey(String value) { + if (value.trim().isEmpty) return null; + return value + .split('_') + .where((part) => part.isNotEmpty) + .map((part) => + part[0].toUpperCase() + part.substring(1).toLowerCase()) + .join(' '); +} diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_activity_log_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_activity_log_screen.dart new file mode 100644 index 0000000..6025e16 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_activity_log_screen.dart @@ -0,0 +1 @@ +export '../../guardian_activity_log_screen.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_ai_config_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_ai_config_screen.dart new file mode 100644 index 0000000..fe84d44 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_ai_config_screen.dart @@ -0,0 +1 @@ +export '../../guardian_ai_config_screen.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_map_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_map_screen.dart new file mode 100644 index 0000000..869f442 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_map_screen.dart @@ -0,0 +1 @@ +export '../../guardian_map_screen.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_send_notification_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_send_notification_screen.dart new file mode 100644 index 0000000..5da2d69 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_send_notification_screen.dart @@ -0,0 +1 @@ +export '../../guardian_send_notification_screen.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_settings_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_settings_screen.dart new file mode 100644 index 0000000..8e3ffe7 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_settings_screen.dart @@ -0,0 +1 @@ +export '../../guardian_settings_screen.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_tools_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_tools_screen.dart new file mode 100644 index 0000000..e76221c --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/presentation/screens/guardian_tools_screen.dart @@ -0,0 +1 @@ +export '../../guardian_tools_screen.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart b/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart index dfe0c7c..93780be 100644 --- a/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart @@ -13,6 +13,7 @@ import '../../../app/injection_container.dart'; import '../../../core/network/api_client.dart'; import '../../../core/services/websocket_service.dart'; import '../../../core/storage/secure_storage.dart'; +import '../../../core/utils/operation_guard.dart'; // ───────────────────────────────────────────────────────────────────────────── // GUARDIAN DASHBOARD SCREEN @@ -57,6 +58,7 @@ class _GuardianDashboardScreenState extends State duration: const Duration(milliseconds: 600), ); bool _sosAlert = false; + List> _pendingSos = const []; // ── Refresh button animation ───────────────────────────────────────────────── late final AnimationController _refreshCtrl = AnimationController( @@ -89,7 +91,8 @@ class _GuardianDashboardScreenState extends State _error = null; }); } - try { + await guarded( + () async { _guardianName = await sl().getDisplayName() ?? 'Guardian'; @@ -103,7 +106,8 @@ class _GuardianDashboardScreenState extends State final dashboard = results[0] as Map?; final activityList = results[1] as List>; - final sosPending = results[2] as int; + final sosPendingEvents = results[2] as List>; + final sosPending = sosPendingEvents.length; // Extract latest GPS from dashboard final lastLoc = dashboard?['lastLocation'] as Map?; @@ -150,6 +154,7 @@ class _GuardianDashboardScreenState extends State recentActivity: activityList, isPaired: userStatus != null || dashboard != null, ); + _pendingSos = sosPendingEvents; if (newLatLng != null) { _liveLatLng = newLatLng; } @@ -163,32 +168,31 @@ class _GuardianDashboardScreenState extends State // Move map to latest location if (newLatLng != null) { - try { - _mapController.move(newLatLng, 15); - } catch (_) {} + _moveMapSafely(newLatLng); } - } catch (e) { - setState(() { + }, + onError: (e) => setState(() { _loading = false; _error = _friendlyError(e); - }); - } + }), + ); } Future?> _fetchDashboard() async { - try { + return await guarded?>( + () async { final res = await _api .get('/guardian/dashboard') .timeout(const Duration(seconds: 8)); final d = res.data['data']; return d is Map ? Map.from(d) : null; - } catch (_) { - return null; - } + }, + ); } Future>> _fetchActivity() async { - try { + return await guarded>>( + () async { final res = await _api .get('/guardian/activity-logs', queryParameters: {'size': 5, 'page': 0}) @@ -202,12 +206,15 @@ class _GuardianDashboardScreenState extends State .map((e) => Map.from(e)) .toList(); } - } catch (_) {} - return const []; + return const []; + }, + ) ?? + const []; } - Future _fetchSosPending() async { - try { + Future>> _fetchSosPending() async { + return await guarded>>( + () async { final res = await _api .get('/guardian/sos-events', queryParameters: {'size': 10, 'page': 0}) @@ -219,17 +226,21 @@ class _GuardianDashboardScreenState extends State return content .whereType() .where((e) => e['status'] == 'TRIGGERED') - .length; + .map((e) => Map.from(e)) + .toList(); } - } catch (_) {} - return 0; + return const []; + }, + ) ?? + const []; } // ── WebSocket subscription ────────────────────────────────────────────────── void _subscribeWebSocket() { final ws = sl(); Future.microtask(() async { - try { + await guarded( + () async { final userId = await _getLinkedUserId(); if (userId == null) return; ws.subscribeLocation(userId, (lat, lng) { @@ -239,26 +250,30 @@ class _GuardianDashboardScreenState extends State _liveLatLng = newPos; _liveConnected = true; }); - try { - _mapController.move(newPos, 15); - } catch (_) {} + _moveMapSafely(newPos); }); ws.subscribeSos((sosData) { if (!mounted) return; _triggerSosFlash(); setState(() { + _pendingSos = [ + Map.from(sosData), + ..._pendingSos, + ]; _data = _data?.copyWith( unreadSos: (_data?.unreadSos ?? 0) + 1); }); _showSosSnackbar(sosData); }); if (mounted) setState(() => _liveConnected = true); - } catch (_) {} + }, + ); }); } Future _getLinkedUserId() async { - try { + return await guarded( + () async { final res = await _api .get('/shared/pairing/status') .timeout(const Duration(seconds: 5)); @@ -267,8 +282,9 @@ class _GuardianDashboardScreenState extends State return d['pairedWithId']?.toString() ?? d['userId']?.toString(); } - } catch (_) {} - return null; + return null; + }, + ); } void _triggerSosFlash() { @@ -303,14 +319,56 @@ class _GuardianDashboardScreenState extends State ), ]), action: SnackBarAction( - label: 'Lihat', + label: 'Tangani', textColor: Colors.white, - onPressed: () => context.go('/guardian/logs'), + onPressed: _handleLatestSos, ), ), ); } + Future _handleLatestSos() async { + final sosId = _pendingSos + .map((e) => e['id']) + .where((id) => id != null) + .map((id) => int.tryParse(id.toString())) + .firstWhere((id) => id != null, orElse: () => null); + if (sosId == null) { + await _loadAll(silent: true); + return; + } + await guarded( + () async { + await _api + .put('/guardian/sos/$sosId/resolve') + .timeout(const Duration(seconds: 8)); + if (!mounted) return; + setState(() { + _pendingSos = + _pendingSos.where((e) => e['id']?.toString() != '$sosId').toList(); + _data = _data?.copyWith(unreadSos: _pendingSos.length); + if (_pendingSos.isEmpty) { + _sosAlert = false; + _sosCtrl.stop(); + } + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('SOS ditandai sudah ditangani.')), + ); + }, + onError: (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Gagal menandai SOS. Coba refresh.')), + ); + }, + ); + } + + void _moveMapSafely(LatLng position) { + guarded(() async => _mapController.move(position, 15)); + } + Future _refresh() async { HapticFeedback.lightImpact(); _refreshCtrl.forward(from: 0); @@ -517,10 +575,10 @@ class _GuardianDashboardScreenState extends State ), ), TextButton( - onPressed: () => context.go('/guardian/logs'), + onPressed: _handleLatestSos, style: TextButton.styleFrom( foregroundColor: Colors.white), - child: const Text('Tangani'), + child: const Text('Handle'), ), IconButton( onPressed: () { 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 65414eb..9007a26 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 @@ -16,6 +16,7 @@ import 'package:latlong2/latlong.dart'; import '../../app/injection_container.dart'; import '../../core/network/api_client.dart'; import '../../core/services/tts_service.dart'; +import '../../core/utils/operation_guard.dart'; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -70,7 +71,8 @@ class _NavState extends Cubit { // ── locate ────────────────────────────────────────────────────────────── Future locate() async { _set(_NavPhase.locating, 'Mencari lokasi GPS…'); - try { + final located = await guarded( + () async { LocationPermission perm = await Geolocator.checkPermission(); if (perm == LocationPermission.denied) { perm = await Geolocator.requestPermission(); @@ -86,14 +88,12 @@ class _NavState extends Cubit { _set(_NavPhase.idle, 'Lokasi ditemukan. Cari tujuan atau ketuk peta.'); _reportToBackend(pos); return true; - } on TimeoutException { - _set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.'); - return false; - } catch (e) { - _set(_NavPhase.error, - 'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.'); - return false; - } + }, + onTimeout: () => _set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.'), + onError: (_) => _set(_NavPhase.error, + 'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.'), + ); + return located ?? false; } void _reportToBackend(Position pos) { @@ -112,7 +112,8 @@ class _NavState extends Cubit { // ── search Nominatim ───────────────────────────────────────────────────── Future> searchPlaces(String query) async { if (query.trim().isEmpty) return const []; - try { + return await guarded>( + () async { final res = await Dio().get( 'https://nominatim.openstreetmap.org/search', queryParameters: { @@ -137,9 +138,8 @@ class _NavState extends Cubit { position: LatLng(lat, lng), ); }).toList(); - } catch (_) { - return const []; - } + }, + ) ?? const []; } String _viewbox(LatLng c) => @@ -147,7 +147,8 @@ class _NavState extends Cubit { // ── reverse geocode ────────────────────────────────────────────────────── Future reverseGeocode(LatLng pos) async { - try { + return await guarded( + () async { final res = await Dio().get( 'https://nominatim.openstreetmap.org/reverse', queryParameters: { @@ -162,9 +163,9 @@ class _NavState extends Cubit { ); return res.data['display_name']?.toString() ?? '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}'; - } catch (_) { - return '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}'; - } + }, + ) ?? + '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}'; } // ── OSRM routing ───────────────────────────────────────────────────────── @@ -177,7 +178,8 @@ class _NavState extends Cubit { _set(_NavPhase.routing, 'Menghitung rute ke ${_shortName(dest)}…'); final origin = currentPosition!; - try { + await guarded( + () async { final url = 'http://router.project-osrm.org/route/v1/foot/' '${origin.longitude},${origin.latitude};' '${dest.position.longitude},${dest.position.latitude}' @@ -219,10 +221,10 @@ class _NavState extends Cubit { steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.'); _notify(); _startTracking(); - } catch (e) { - _set(_NavPhase.error, - 'Gagal mendapat rute. Periksa koneksi lalu coba lagi.'); - } + }, + onError: (_) => _set(_NavPhase.error, + 'Gagal mendapat rute. Periksa koneksi lalu coba lagi.'), + ); } String _shortName(_Place p) { diff --git a/walkguide-mobile/walkguide_app/lib/features/navigation_mode/presentation/screens/navigation_mode_screen.dart b/walkguide-mobile/walkguide_app/lib/features/navigation_mode/presentation/screens/navigation_mode_screen.dart new file mode 100644 index 0000000..ee2b87f --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/navigation_mode/presentation/screens/navigation_mode_screen.dart @@ -0,0 +1 @@ +export '../../navigation_mode_screen.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/notifications/application/notification_cubit.dart b/walkguide-mobile/walkguide_app/lib/features/notifications/application/notification_cubit.dart new file mode 100644 index 0000000..4989ca9 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/notifications/application/notification_cubit.dart @@ -0,0 +1,91 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../domain/entities/guardian_notification.dart'; +import '../domain/repositories/notification_repository.dart'; + +class NotificationState { + final bool loading; + final List items; + final String? error; + final bool markingAll; + + const NotificationState({ + this.loading = false, + this.items = const [], + this.error, + this.markingAll = false, + }); + + NotificationState copyWith({ + bool? loading, + List? items, + String? error, + bool? markingAll, + }) { + return NotificationState( + loading: loading ?? this.loading, + items: items ?? this.items, + error: error, + markingAll: markingAll ?? this.markingAll, + ); + } +} + +class NotificationCubit extends Cubit { + final NotificationRepository _repository; + + NotificationCubit(this._repository) : super(const NotificationState()); + + Future load() async { + emit(const NotificationState(loading: true)); + final result = await _repository.getNotifications(); + result.fold( + (failure) => emit(NotificationState(error: failure.message)), + (items) => emit(NotificationState(items: items)), + ); + } + + Future markOneRead(int id) async { + final result = await _repository.markOneRead(id); + result.fold( + (failure) => emit(state.copyWith(error: failure.message)), + (_) => emit(state.copyWith( + items: state.items + .map((item) => item.id == id + ? GuardianNotificationEntity( + id: item.id, + notificationType: item.notificationType, + content: item.content, + voiceNoteUrl: item.voiceNoteUrl, + voiceNoteDuration: item.voiceNoteDuration, + isRead: true, + createdAt: item.createdAt, + ) + : item) + .toList(), + )), + ); + } + + Future markAllRead() async { + emit(state.copyWith(markingAll: true)); + final result = await _repository.markAllRead(); + result.fold( + (failure) => emit(state.copyWith(error: failure.message, markingAll: false)), + (_) => emit(state.copyWith( + markingAll: false, + items: state.items + .map((item) => GuardianNotificationEntity( + id: item.id, + notificationType: item.notificationType, + content: item.content, + voiceNoteUrl: item.voiceNoteUrl, + voiceNoteDuration: item.voiceNoteDuration, + isRead: true, + createdAt: item.createdAt, + )) + .toList(), + )), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/notifications/data/repositories/notification_repository_impl.dart b/walkguide-mobile/walkguide_app/lib/features/notifications/data/repositories/notification_repository_impl.dart new file mode 100644 index 0000000..7fcfa85 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/notifications/data/repositories/notification_repository_impl.dart @@ -0,0 +1,90 @@ +import 'package:dartz/dartz.dart'; + +import '../../../../core/errors/failures.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/storage/local_database.dart'; +import '../../domain/entities/guardian_notification.dart'; +import '../../domain/repositories/notification_repository.dart'; + +class NotificationRepositoryImpl implements NotificationRepository { + final ApiClient _apiClient; + final LocalDatabase _database; + + const NotificationRepositoryImpl(this._apiClient, this._database); + + @override + Future>> getNotifications() async { + try { + final response = await _apiClient.dio.get('/user/notifications'); + final body = response.data; + final data = body is Map ? body['data'] : body; + final rawList = data is Map ? data['content'] : data; + final list = rawList is List + ? rawList + .whereType() + .map((item) => _fromJson(Map.from(item))) + .toList() + : []; + await _database.notifications.replaceAll( + list + .map((item) => CachedNotification( + id: item.id, + notificationType: item.notificationType, + content: item.content, + voiceNoteUrl: item.voiceNoteUrl, + isRead: item.isRead, + createdAt: item.createdAt ?? DateTime.now(), + )) + .toList(), + ); + return Right(list); + } catch (_) { + final cached = await _database.notifications.getAll(); + return Right( + cached + .map((item) => GuardianNotificationEntity( + id: item.id, + notificationType: item.notificationType, + content: item.content, + voiceNoteUrl: item.voiceNoteUrl, + isRead: item.isRead, + createdAt: item.createdAt, + )) + .toList(), + ); + } + } + + @override + Future> markAllRead() async { + try { + await _apiClient.dio.put('/user/notifications/mark-all-read'); + return const Right(null); + } catch (_) { + return const Left(NetworkFailure('Gagal menandai semua notifikasi.')); + } + } + + @override + Future> markOneRead(int id) async { + try { + await _apiClient.dio.put('/user/notifications/$id/read'); + return const Right(null); + } catch (_) { + return const Left(NetworkFailure('Gagal menandai notifikasi.')); + } + } +} + +GuardianNotificationEntity _fromJson(Map json) { + return GuardianNotificationEntity( + id: (json['id'] as num?)?.toInt(), + notificationType: + json['notifType']?.toString() ?? json['notificationType']?.toString() ?? 'TEXT', + content: json['content']?.toString(), + voiceNoteUrl: json['voiceNoteUrl']?.toString(), + voiceNoteDuration: (json['voiceNoteDuration'] as num?)?.toInt(), + isRead: json['isRead'] == true, + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''), + ); +} diff --git a/walkguide-mobile/walkguide_app/lib/features/notifications/domain/entities/guardian_notification.dart b/walkguide-mobile/walkguide_app/lib/features/notifications/domain/entities/guardian_notification.dart new file mode 100644 index 0000000..7fab7a0 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/notifications/domain/entities/guardian_notification.dart @@ -0,0 +1,19 @@ +class GuardianNotificationEntity { + final int? id; + final String notificationType; + final String? content; + final String? voiceNoteUrl; + final int? voiceNoteDuration; + final bool isRead; + final DateTime? createdAt; + + const GuardianNotificationEntity({ + this.id, + required this.notificationType, + this.content, + this.voiceNoteUrl, + this.voiceNoteDuration, + this.isRead = false, + this.createdAt, + }); +} diff --git a/walkguide-mobile/walkguide_app/lib/features/notifications/domain/repositories/notification_repository.dart b/walkguide-mobile/walkguide_app/lib/features/notifications/domain/repositories/notification_repository.dart new file mode 100644 index 0000000..a07c6af --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/notifications/domain/repositories/notification_repository.dart @@ -0,0 +1,10 @@ +import 'package:dartz/dartz.dart'; + +import '../../../../core/errors/failures.dart'; +import '../entities/guardian_notification.dart'; + +abstract class NotificationRepository { + Future>> getNotifications(); + Future> markAllRead(); + Future> markOneRead(int id); +} 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 23dd93e..4e67af4 100644 --- a/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart @@ -2,16 +2,19 @@ // ignore_for_file: use_build_context_synchronously import 'dart:async'; -import 'package:dio/dio.dart'; +import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:path_provider/path_provider.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'; - -Dio get _api => sl().dio; +import 'application/notification_cubit.dart'; +import 'domain/entities/guardian_notification.dart'; class NotificationScreen extends StatefulWidget { const NotificationScreen({super.key}); @@ -21,58 +24,31 @@ class NotificationScreen extends StatefulWidget { } class _NotificationScreenState extends State { - List<_NotifItem> _items = []; - bool _loading = true; - String? _error; - bool _markingAll = false; + late final NotificationCubit _notificationCubit; + final AudioPlayer _audioPlayer = AudioPlayer(); @override void initState() { super.initState(); + _notificationCubit = sl(); _load(); } - Future _load() async { - setState(() { - _loading = true; - _error = null; - }); - 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); + @override + void dispose() { + _audioPlayer.dispose(); + _notificationCubit.close(); + super.dispose(); } - List> _extractList(dynamic responseBody) { - final data = responseBody is Map ? responseBody['data'] : null; - final rawList = data is Map ? data['content'] : data; - if (rawList is! List) return const []; - return rawList - .whereType() - .map((item) => Map.from(item)) - .toList(); + Future _load() async { + await _notificationCubit.load(); } Future _markRead(int id) async { 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); - }); + await _notificationCubit.markOneRead(id); }, onError: (_) {}, fallback: 'Gagal menandai notifikasi.', @@ -80,45 +56,80 @@ class _NotificationScreenState extends State { } Future _markAllRead() async { - setState(() => _markingAll = true); 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(); - }); + await _notificationCubit.markAllRead(); _snack('Semua notifikasi ditandai sudah dibaca.'); }, onError: _snack, fallback: 'Gagal menandai semua dibaca.', ); - if (mounted) setState(() => _markingAll = false); } Future _readAloud(_NotifItem notif) async { - final tts = sl(); - tts.speak(notif.content ?? 'Voice note dari Guardian.'); + if (notif.type == 'VOICE_NOTE') { + final source = notif.voiceNoteUrl == 'inline-audio' + ? notif.content + : notif.voiceNoteUrl; + if (source == null || source.isEmpty) { + _snack('Voice note kosong atau belum tersedia.'); + return; + } + await _playVoiceNote(source); + } else { + final tts = sl(); + tts.speak(notif.content ?? 'Pesan dari Guardian.'); + } await _markRead(notif.id); } + Future _playVoiceNote(String source) async { + await runFriendlyAction( + () async { + String? localPath; + if (source.startsWith('data:audio')) { + final comma = source.indexOf(','); + if (comma == -1) { + throw const FormatException('Invalid inline audio payload'); + } + final bytes = base64Decode(source.substring(comma + 1)); + final dir = await getTemporaryDirectory(); + final file = File( + '${dir.path}/walkguide_voice_${DateTime.now().millisecondsSinceEpoch}.m4a'); + await file.writeAsBytes(bytes, flush: true); + localPath = file.path; + } + if (localPath != null) { + await _audioPlayer.setFilePath(localPath); + } else { + await _audioPlayer.setUrl(source); + } + await _audioPlayer.play(); + }, + onError: _snack, + fallback: 'Voice note belum bisa diputar.', + ); + } + void _snack(String msg) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } } - int get _unreadCount => _items.where((n) => !n.isRead).length; - @override Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + return BlocBuilder( + bloc: _notificationCubit, + builder: (context, state) { + final items = state.items.map(_NotifItem.fromEntity).toList(); + final unreadCount = items.where((n) => !n.isRead).length; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ // Header Row( children: [ @@ -135,9 +146,9 @@ class _NotificationScreenState extends State { .headlineSmall ?.copyWith(fontWeight: FontWeight.w800), ), - if (_unreadCount > 0) ...[ + if (unreadCount > 0) ...[ const SizedBox(width: 8), - _UnreadBadge(count: _unreadCount), + _UnreadBadge(count: unreadCount), ], ], ), @@ -148,10 +159,10 @@ class _NotificationScreenState extends State { ], ), ), - if (_unreadCount > 0) + if (unreadCount > 0) TextButton.icon( - onPressed: _markingAll ? null : _markAllRead, - icon: _markingAll + onPressed: state.markingAll ? null : _markAllRead, + icon: state.markingAll ? const SizedBox( width: 14, height: 14, @@ -170,29 +181,31 @@ class _NotificationScreenState extends State { // Body Expanded( - child: _loading + child: state.loading ? const Center(child: CircularProgressIndicator()) - : _error != null - ? _ErrorPanel(message: _error!, onRetry: _load) - : _items.isEmpty + : state.error != null + ? _ErrorPanel(message: state.error!, onRetry: _load) + : items.isEmpty ? const _EmptyPanel() : RefreshIndicator( onRefresh: _load, child: ListView.separated( - itemCount: _items.length, + itemCount: items.length, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (ctx, i) => _NotifCard( - notif: _items[i], - onMarkRead: () => _markRead(_items[i].id), - onReadAloud: () => _readAloud(_items[i]), + notif: items[i], + onMarkRead: () => _markRead(items[i].id), + onReadAloud: () => _readAloud(items[i]), ), ), ), ), ], - ), - ), + ), + ), + ); + }, ); } } @@ -216,14 +229,14 @@ class _NotifItem { required this.createdAt, }); - factory _NotifItem.fromJson(Map j) => _NotifItem( - id: j['id'] as int, - type: j['notifType']?.toString() ?? 'TEXT', - content: j['content']?.toString(), - voiceNoteUrl: j['voiceNoteUrl']?.toString(), - isRead: j['isRead'] == true, - createdAt: DateTime.tryParse(j['createdAt']?.toString() ?? '') ?? - DateTime.now(), + factory _NotifItem.fromEntity(GuardianNotificationEntity entity) => + _NotifItem( + id: entity.id ?? 0, + type: entity.notificationType, + content: entity.content, + voiceNoteUrl: entity.voiceNoteUrl, + isRead: entity.isRead, + createdAt: entity.createdAt ?? DateTime.now(), ); _NotifItem copyWith({bool? isRead}) => _NotifItem( @@ -329,7 +342,9 @@ class _NotifCard extends StatelessWidget { ), ], ), - if (notif.content != null && notif.content!.isNotEmpty) ...[ + if (!isVoice && + notif.content != null && + notif.content!.isNotEmpty) ...[ const SizedBox(height: 10), Text( notif.content!, @@ -342,8 +357,10 @@ class _NotifCard extends StatelessWidget { // Read aloud button OutlinedButton.icon( onPressed: onReadAloud, - icon: const Icon(Icons.volume_up, size: 16), - label: const Text('Bacakan', style: TextStyle(fontSize: 13)), + icon: Icon(isVoice ? Icons.play_arrow : Icons.volume_up, + size: 16), + label: Text(isVoice ? 'Putar' : 'Bacakan', + style: const TextStyle(fontSize: 13)), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), diff --git a/walkguide-mobile/walkguide_app/lib/features/notifications/presentation/screens/notification_screen.dart b/walkguide-mobile/walkguide_app/lib/features/notifications/presentation/screens/notification_screen.dart new file mode 100644 index 0000000..babd9e9 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/notifications/presentation/screens/notification_screen.dart @@ -0,0 +1 @@ +export '../../notification_screen.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/pairing/presentation/screens/pairing_screens.dart b/walkguide-mobile/walkguide_app/lib/features/pairing/presentation/screens/pairing_screens.dart new file mode 100644 index 0000000..e0d2a6f --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/pairing/presentation/screens/pairing_screens.dart @@ -0,0 +1 @@ +export '../../pairing_screens.dart'; 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 bf1b0f4..0fb610d 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 @@ -27,7 +27,7 @@ class ServerConnectScreen extends StatefulWidget { } class _ServerConnectScreenState extends State { - final _url = TextEditingController(text: 'http://202.46.28.160:8080'); + final _url = TextEditingController(); bool _loading = false; bool _ok = false; String? _message; @@ -76,7 +76,7 @@ class _ServerConnectScreenState extends State { keyboardType: TextInputType.url, decoration: const InputDecoration( labelText: 'Server URL', - hintText: 'http://202.46.28.160:8080', + hintText: 'http://server-ip:8080', prefixIcon: Icon(Icons.dns_outlined), )), const SizedBox(height: 12), diff --git a/walkguide-mobile/walkguide_app/lib/features/settings/presentation/screens/user_settings_screen.dart b/walkguide-mobile/walkguide_app/lib/features/settings/presentation/screens/user_settings_screen.dart new file mode 100644 index 0000000..b5e59dc --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/settings/presentation/screens/user_settings_screen.dart @@ -0,0 +1 @@ +export '../../user_settings_screen.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/sos/application/sos_cubit.dart b/walkguide-mobile/walkguide_app/lib/features/sos/application/sos_cubit.dart new file mode 100644 index 0000000..a84193b --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/sos/application/sos_cubit.dart @@ -0,0 +1,38 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../domain/repositories/sos_repository.dart'; + +enum SosPhase { idle, sending, sent, error } + +class SosState { + final SosPhase phase; + final String? message; + + const SosState({this.phase = SosPhase.idle, this.message}); +} + +class SosCubit extends Cubit { + final SosRepository _repository; + + SosCubit(this._repository) : super(const SosState()); + + Future trigger({ + String triggerType = 'MANUAL', + double? lat, + double? lng, + }) async { + emit(const SosState(phase: SosPhase.sending)); + final result = await _repository.triggerSos( + triggerType: triggerType, + lat: lat, + lng: lng, + ); + result.fold( + (failure) => emit(SosState(phase: SosPhase.error, message: failure.message)), + (_) => emit(const SosState( + phase: SosPhase.sent, + message: 'SOS terkirim. Guardian sudah diberi tahu.', + )), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/sos/data/repositories/sos_repository_impl.dart b/walkguide-mobile/walkguide_app/lib/features/sos/data/repositories/sos_repository_impl.dart new file mode 100644 index 0000000..7aecca7 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/sos/data/repositories/sos_repository_impl.dart @@ -0,0 +1,29 @@ +import 'package:dartz/dartz.dart'; + +import '../../../../core/errors/failures.dart'; +import '../../../../core/network/api_client.dart'; +import '../../domain/repositories/sos_repository.dart'; + +class SosRepositoryImpl implements SosRepository { + final ApiClient _apiClient; + + const SosRepositoryImpl(this._apiClient); + + @override + Future> triggerSos({ + required String triggerType, + double? lat, + double? lng, + }) async { + try { + await _apiClient.dio.post('/user/sos', data: { + 'triggerType': triggerType, + 'lat': lat, + 'lng': lng, + }); + return const Right(null); + } catch (_) { + return const Left(NetworkFailure('SOS belum terkirim. Coba lagi.')); + } + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/sos/domain/entities/sos_event.dart b/walkguide-mobile/walkguide_app/lib/features/sos/domain/entities/sos_event.dart new file mode 100644 index 0000000..8449647 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/sos/domain/entities/sos_event.dart @@ -0,0 +1,17 @@ +class SosEvent { + final int? id; + final String triggerType; + final double? lat; + final double? lng; + final String status; + final DateTime? createdAt; + + const SosEvent({ + this.id, + required this.triggerType, + this.lat, + this.lng, + this.status = 'TRIGGERED', + this.createdAt, + }); +} diff --git a/walkguide-mobile/walkguide_app/lib/features/sos/domain/repositories/sos_repository.dart b/walkguide-mobile/walkguide_app/lib/features/sos/domain/repositories/sos_repository.dart new file mode 100644 index 0000000..b4bbc52 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/sos/domain/repositories/sos_repository.dart @@ -0,0 +1,11 @@ +import 'package:dartz/dartz.dart'; + +import '../../../../core/errors/failures.dart'; + +abstract class SosRepository { + Future> triggerSos({ + required String triggerType, + double? lat, + double? lng, + }); +} diff --git a/walkguide-mobile/walkguide_app/lib/features/sos/presentation/screens/sos_screen.dart b/walkguide-mobile/walkguide_app/lib/features/sos/presentation/screens/sos_screen.dart new file mode 100644 index 0000000..f2083dc --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/sos/presentation/screens/sos_screen.dart @@ -0,0 +1 @@ +export '../../sos_screen.dart'; 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 608f855..0042155 100644 --- a/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:geolocator/geolocator.dart'; import '../../app/injection_container.dart'; @@ -12,6 +13,7 @@ import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/services/haptic_service.dart'; import '../../core/services/tts_service.dart'; +import 'application/sos_cubit.dart'; Dio get _api => sl().dio; @@ -61,7 +63,7 @@ class SosScreen extends StatefulWidget { class _SosScreenState extends State with SingleTickerProviderStateMixin { // State - bool _sending = false; + late final SosCubit _sosCubit; bool _historyLoading = true; List<_SosEvent> _events = const []; String? _historyError; @@ -76,6 +78,7 @@ class _SosScreenState extends State @override void initState() { super.initState(); + _sosCubit = sl(); _pulseCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), @@ -89,6 +92,7 @@ class _SosScreenState extends State @override void dispose() { _pulseCtrl.dispose(); + _sosCubit.close(); super.dispose(); } @@ -134,7 +138,7 @@ class _SosScreenState extends State } Future _confirmAndSend() async { - if (_sending) return; + if (_sosCubit.state.phase == SosPhase.sending) return; // Confirmation dialog — prevents accidental tap final confirm = await showDialog( @@ -178,15 +182,17 @@ class _SosScreenState extends State } Future _sendSos() async { - setState(() => _sending = true); await runFriendlyAction( () async { final pos = await _getPosition(); - await _api.post('/user/sos', data: { - 'triggerType': 'BUTTON', - 'lat': pos?.latitude, - 'lng': pos?.longitude, - }); + await _sosCubit.trigger( + triggerType: 'BUTTON', + lat: pos?.latitude, + lng: pos?.longitude, + ); + if (_sosCubit.state.phase == SosPhase.error) { + throw StateError(_sosCubit.state.message ?? 'Gagal kirim SOS.'); + } await sl().sosTriggered(); sl().speak('SOS terkirim ke Guardian.'); _snack('SOS berhasil dikirim! Guardian sudah diberitahu.'); @@ -195,19 +201,22 @@ class _SosScreenState extends State onError: _snack, fallback: 'Gagal kirim SOS. Coba lagi sebentar.', ); - if (mounted) setState(() => _sending = false); } // ── Build ────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + return BlocBuilder( + bloc: _sosCubit, + builder: (context, sosState) { + final sending = sosState.phase == SosPhase.sending; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ // Header Row( children: [ @@ -247,7 +256,7 @@ class _SosScreenState extends State // SOS Button Center( - child: _sending + child: sending ? const _SendingIndicator() : AnimatedBuilder( animation: _pulseAnim, @@ -288,15 +297,17 @@ class _SosScreenState extends State const SizedBox(height: 10), Expanded( - child: _SosHistory( - loading: _historyLoading, - error: _historyError, - events: _events, - onRefresh: _loadHistory, - )), - ], + child: _SosHistory( + loading: _historyLoading, + error: _historyError, + events: _events, + onRefresh: _loadHistory, + )), + ], + ), ), - ), + ); + }, ); } diff --git a/walkguide-mobile/walkguide_app/lib/features/walk_guide/application/walk_guide_cubit.dart b/walkguide-mobile/walkguide_app/lib/features/walk_guide/application/walk_guide_cubit.dart new file mode 100644 index 0000000..bc9e597 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/walk_guide/application/walk_guide_cubit.dart @@ -0,0 +1,64 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/ai/obstacle_analyzer.dart'; +import '../domain/repositories/walk_guide_repository.dart'; + +class WalkGuideState { + final bool active; + final DetectionResult? latestDetection; + final String status; + + const WalkGuideState({ + this.active = false, + this.latestDetection, + this.status = 'Ready', + }); + + WalkGuideState copyWith({ + bool? active, + DetectionResult? latestDetection, + String? status, + }) { + return WalkGuideState( + active: active ?? this.active, + latestDetection: latestDetection ?? this.latestDetection, + status: status ?? this.status, + ); + } +} + +class WalkGuideCubit extends Cubit { + final WalkGuideRepository _repository; + + WalkGuideCubit(this._repository) : super(const WalkGuideState()); + + Future start() async { + emit(state.copyWith(active: true, status: 'WalkGuide active')); + await _repository.startSession(); + } + + Future stop() async { + emit(state.copyWith(active: false, status: 'WalkGuide stopped')); + await _repository.stopSession(); + } + + void updateStatus(String status) { + emit(state.copyWith(status: status)); + } + + void clearDetection({String status = 'Ready'}) { + emit(WalkGuideState(active: state.active, status: status)); + } + + Future recordObstacle(DetectionResult detection) async { + emit(state.copyWith(latestDetection: detection, status: detection.spokenId)); + await _repository.logObstacle({ + 'label': detection.label, + 'confidence': detection.confidence, + 'direction': detection.directionName, + 'estimatedDist': detection.estimatedDistance, + 'lat': null, + 'lng': null, + }); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/walk_guide/data/repositories/walk_guide_repository_impl.dart b/walkguide-mobile/walkguide_app/lib/features/walk_guide/data/repositories/walk_guide_repository_impl.dart new file mode 100644 index 0000000..8a02a14 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/walk_guide/data/repositories/walk_guide_repository_impl.dart @@ -0,0 +1,46 @@ +import 'package:dartz/dartz.dart'; + +import '../../../../core/errors/failures.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/services/offline_queue_service.dart'; +import '../../domain/repositories/walk_guide_repository.dart'; + +class WalkGuideRepositoryImpl implements WalkGuideRepository { + final ApiClient _apiClient; + final OfflineQueueService _offlineQueue; + + const WalkGuideRepositoryImpl(this._apiClient, this._offlineQueue); + + @override + Future> startSession() { + return _post('/user/walkguide/start', const {}); + } + + @override + Future> stopSession() { + return _post('/user/walkguide/stop', const {}); + } + + @override + Future> logObstacle(Map payload) { + return _post('/user/obstacle', payload); + } + + Future> _post( + String path, + Map payload, + ) async { + try { + await _apiClient.dio.post(path, data: payload); + return const Right(null); + } catch (_) { + await _offlineQueue.enqueue(OfflineRequest( + method: 'POST', + path: path, + body: payload, + createdAt: DateTime.now(), + )); + return const Right(null); + } + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/walk_guide/domain/entities/walk_session.dart b/walkguide-mobile/walkguide_app/lib/features/walk_guide/domain/entities/walk_session.dart new file mode 100644 index 0000000..553c499 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/walk_guide/domain/entities/walk_session.dart @@ -0,0 +1,13 @@ +class WalkSession { + final String sessionId; + final DateTime startedAt; + final DateTime? stoppedAt; + final int obstacleCount; + + const WalkSession({ + required this.sessionId, + required this.startedAt, + this.stoppedAt, + this.obstacleCount = 0, + }); +} diff --git a/walkguide-mobile/walkguide_app/lib/features/walk_guide/domain/repositories/walk_guide_repository.dart b/walkguide-mobile/walkguide_app/lib/features/walk_guide/domain/repositories/walk_guide_repository.dart new file mode 100644 index 0000000..ad17335 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/walk_guide/domain/repositories/walk_guide_repository.dart @@ -0,0 +1,9 @@ +import 'package:dartz/dartz.dart'; + +import '../../../../core/errors/failures.dart'; + +abstract class WalkGuideRepository { + Future> startSession(); + Future> stopSession(); + Future> logObstacle(Map payload); +} diff --git a/walkguide-mobile/walkguide_app/lib/features/walk_guide/presentation/screens/walk_guide_screen.dart b/walkguide-mobile/walkguide_app/lib/features/walk_guide/presentation/screens/walk_guide_screen.dart new file mode 100644 index 0000000..b1d4f08 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/walk_guide/presentation/screens/walk_guide_screen.dart @@ -0,0 +1 @@ +export '../../walk_guide_screen.dart'; 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 f8f40a0..7d7c931 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 @@ -5,14 +5,16 @@ import 'dart:async'; import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../app/injection_container.dart'; import '../../core/ai/detection_export.dart'; -import '../../core/network/api_client.dart'; -import '../../core/services/haptic_service.dart'; +import '../../core/ai/obstacle_alert_strategy.dart'; +import '../../core/errors/friendly_error.dart'; import '../../core/services/location_reporter_service.dart'; import '../../core/services/tts_service.dart'; +import 'application/walk_guide_cubit.dart'; // --------------------------------------------------------------------------- // WalkGuideScreen @@ -26,15 +28,19 @@ class WalkGuideScreen extends StatefulWidget { } class _WalkGuideScreenState extends State { - bool _active = false; - String _status = 'Ready'; + late final WalkGuideCubit _cubit; CameraController? _camera; - DetectionResult? _lastDetection; bool _processingFrame = false; DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0); + @override + void initState() { + super.initState(); + _cubit = sl(); + } + @override void dispose() { final camera = _camera; @@ -43,25 +49,23 @@ class _WalkGuideScreenState extends State { } _camera?.dispose(); sl().stop(); + _cubit.close(); super.dispose(); } Future _toggle() async { - final next = !_active; + final next = !_cubit.state.active; if (next) { await _startCamera(); await sl().start(walkGuideActive: true); + await _cubit.start(); + _cubit.updateStatus(_activeStatusText()); } else { await _stopCamera(); await sl().stop(); + await _cubit.stop(); + _cubit.clearDetection(status: 'Stopped'); } - setState(() { - _active = next; - _status = next ? _activeStatusText() : 'Stopped'; - }); - await sl() - .dio - .post(next ? '/user/walkguide/start' : '/user/walkguide/stop'); sl().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan'); } @@ -80,7 +84,8 @@ class _WalkGuideScreenState extends State { Future _startCamera() async { if (_camera != null) return; - try { + await runFriendlyAction( + () async { final cameras = await availableCameras(); if (cameras.isEmpty) return; final backCamera = cameras.firstWhere( @@ -98,33 +103,40 @@ class _WalkGuideScreenState extends State { await controller.dispose(); return; } - try { - await controller.startImageStream(_onCameraImage); - } catch (_) { - setState(() => _status = kIsWeb - ? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.' - : 'Camera preview aktif, tapi image stream belum tersedia.'); - } + await runFriendlyAction( + () => controller.startImageStream(_onCameraImage), + onError: (_) { + _cubit.updateStatus(kIsWeb + ? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.' + : 'Camera preview aktif, tapi image stream belum tersedia.'); + }, + fallback: 'Camera preview aktif, tapi image stream belum tersedia.', + ); setState(() => _camera = controller); - } catch (_) { - setState(() => _status = 'Camera unavailable.'); - } + }, + onError: (_) => _cubit.updateStatus('Camera unavailable.'), + fallback: 'Camera unavailable.', + ); } Future _stopCamera() async { final camera = _camera; _camera = null; if (camera == null) return; - try { - if (camera.value.isStreamingImages) { - await camera.stopImageStream(); - } - } catch (_) {} + await runFriendlyAction( + () async { + if (camera.value.isStreamingImages) { + await camera.stopImageStream(); + } + }, + onError: (_) {}, + fallback: 'Camera stream already stopped.', + ); await camera.dispose(); } void _onCameraImage(CameraImage image) { - if (!_active || _processingFrame) return; + if (!_cubit.state.active || _processingFrame) return; final now = DateTime.now(); if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) { return; @@ -141,7 +153,7 @@ class _WalkGuideScreenState extends State { final now = DateTime.now(); if (now.difference(_lastModelWarningAt) > const Duration(seconds: 3)) { _lastModelWarningAt = now; - setState(() => _status = detector.isReady + _cubit.updateStatus(detector.isReady ? 'Scanning... ${detector.diagnosticsSummary}' : 'YOLO model belum siap. Pastikan file model tersedia di assets/models.'); } @@ -154,8 +166,7 @@ class _WalkGuideScreenState extends State { DetectionResult detection, { bool forceAlert = false, }) async { - _lastDetection = detection; - setState(() => _status = + _cubit.updateStatus( 'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}'); final now = DateTime.now(); @@ -165,94 +176,94 @@ class _WalkGuideScreenState extends State { } _lastAlertAt = now; - try { - await sl().dio.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); + await runFriendlyAction( + () => _cubit.recordObstacle(detection), + onError: (_) {}, + fallback: 'Obstacle tersimpan offline.', + ); + await sl().alert(detection); } @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)), - if (_lastDetection?.box != null) - Positioned.fill( - child: CustomPaint( - painter: _DetectionOverlayPainter(_lastDetection!), + return BlocBuilder( + bloc: _cubit, + builder: (context, state) => _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)), + if (state.latestDetection?.box != null) + Positioned.fill( + child: CustomPaint( + painter: + _DetectionOverlayPainter(state.latestDetection!), + ), ), - ), - 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: - '${ObstacleAnalyzer.spokenLabel(_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))), - ], + top: 16, + left: 16, + child: _Pill( + text: state.active ? 'AI ACTIVE' : 'STANDBY', + color: + state.active ? Colors.green : Colors.orange)), + if (state.latestDetection != null) + Positioned( + top: 64, + left: 16, + child: _Pill( + text: + '${ObstacleAnalyzer.spokenLabel(state.latestDetection!.label)} ${state.latestDetection!.directionName}', + color: Colors.redAccent), + ), + Positioned( + left: 16, + right: 16, + bottom: 16, + child: Text(state.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(height: 14), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _toggle, + icon: + Icon(state.active ? Icons.stop : Icons.play_arrow), + label: Text(state.active ? 'Stop' : 'Start'))), + ], + ), + ], + ), ), ); } diff --git a/walkguide-mobile/walkguide_app/lib/main.dart b/walkguide-mobile/walkguide_app/lib/main.dart index 37da5c6..0777bbe 100644 --- a/walkguide-mobile/walkguide_app/lib/main.dart +++ b/walkguide-mobile/walkguide_app/lib/main.dart @@ -4,25 +4,21 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'app/injection_container.dart'; import 'app/app.dart'; +import 'core/utils/init_guard.dart'; List cameras = []; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - // Init cameras - try { - cameras = await availableCameras(); - } catch (e) { - debugPrint('Camera init error: $e'); - } + cameras = await ignoreInitFailure( + availableCameras, + label: 'Camera init', + ) ?? + []; if (!kIsWeb) { - try { - await Firebase.initializeApp(); - } catch (e) { - debugPrint('Firebase init skipped: $e'); - } + await ignoreInitFailure(() => Firebase.initializeApp(), label: 'Firebase init'); } // Init GetIt dependencies diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart index ffff70a..9cb0c7f 100644 --- a/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart @@ -4,6 +4,9 @@ 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/network/api_client.dart'; +import '../../core/services/hardware_shortcut_listener.dart'; import '../../core/services/stt_service.dart'; import '../../core/services/tts_service.dart'; import '../../core/services/voice_command_handler.dart'; @@ -20,6 +23,8 @@ class _UserShellState extends State { @override void initState() { super.initState(); + _loadVoiceCommands(); + _startHardwareShortcuts(); sl().startListening(); sl().onCommand = (key) { if (!mounted) return; @@ -58,6 +63,70 @@ class _UserShellState extends State { }; } + Future _loadVoiceCommands() async { + await runFriendlyAction( + () async { + final res = await sl() + .dio + .get('/user/voice-commands') + .timeout(const Duration(seconds: 8)); + final body = res.data; + final data = body is Map ? body['data'] : body; + if (data is! List) return; + final commands = data + .whereType() + .map((item) => _voiceCommandFromJson(Map.from(item))) + .whereType() + .toList(); + if (commands.isNotEmpty) { + sl().loadCommands(commands); + } + }, + onError: (_) {}, + fallback: 'Voice command belum bisa dimuat.', + ); + } + + Future _startHardwareShortcuts() async { + await runFriendlyAction( + () => sl().startListening( + onAction: (action) { + if (!mounted) return; + switch (action) { + case HardwareShortcutAction.callGuardian: + context.go('/user/call'); + sl().speak('Memanggil guardian'); + break; + case HardwareShortcutAction.startWalkguide: + context.go('/user/walkguide'); + sl().speak('WalkGuide dibuka'); + break; + case HardwareShortcutAction.stopWalkguide: + context.go('/user/walkguide'); + sl().speak('WalkGuide dibuka untuk dihentikan'); + break; + case HardwareShortcutAction.sendSos: + context.go('/user/sos'); + sl().speak('SOS dibuka'); + break; + case HardwareShortcutAction.openNotification: + context.go('/user/notifications'); + sl().speak('Notifikasi dibuka'); + break; + } + }, + ), + onError: (_) {}, + fallback: 'Hardware shortcut belum bisa dimuat.', + ); + } + + @override + void dispose() { + sl().stopListening(); + super.dispose(); + } + @override Widget build(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; @@ -169,3 +238,49 @@ String _spokenRouteName(VoiceCommandKey key) { return ''; } } + +VoiceCommand? _voiceCommandFromJson(Map item) { + final key = _commandKeyFromBackend(item['commandKey']?.toString()); + final phrase = item['triggerPhrase']?.toString().trim(); + if (key == null || phrase == null || phrase.isEmpty) return null; + return VoiceCommand( + key: key, + phrase: phrase, + enabled: item['enabled'] != false, + ); +} + +VoiceCommandKey? _commandKeyFromBackend(String? key) { + switch (key) { + case 'OPEN_WALKGUIDE': + return VoiceCommandKey.openWalkguide; + case 'START_WALKGUIDE': + return VoiceCommandKey.startWalkguide; + case 'STOP_WALKGUIDE': + return VoiceCommandKey.stopWalkguide; + case 'CALL_GUARDIAN': + return VoiceCommandKey.callGuardian; + case 'OPEN_NOTIFICATION': + return VoiceCommandKey.openNotification; + case 'READ_ALL_NOTIF': + return VoiceCommandKey.readAllNotif; + case 'OPEN_SOS': + return VoiceCommandKey.openSos; + case 'SEND_SOS': + return VoiceCommandKey.sendSos; + case 'WHERE_AM_I': + return VoiceCommandKey.whereAmI; + case 'OPEN_ACTIVITY': + return VoiceCommandKey.openActivity; + case 'OPEN_NAVIGATION': + return VoiceCommandKey.openNavigation; + case 'OPEN_SETTINGS': + return VoiceCommandKey.openSettings; + case 'REPEAT_LAST': + return VoiceCommandKey.repeatLast; + case 'STOP_TTS': + return VoiceCommandKey.stopTts; + default: + return null; + } +} diff --git a/walkguide-mobile/walkguide_app/pubspec.lock b/walkguide-mobile/walkguide_app/pubspec.lock index cb87f98..42a445f 100644 --- a/walkguide-mobile/walkguide_app/pubspec.lock +++ b/walkguide-mobile/walkguide_app/pubspec.lock @@ -947,10 +947,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mgrs_dart: dependency: transitive description: @@ -1501,7 +1501,7 @@ packages: source: hosted version: "2.4.0" sqlite3: - dependency: transitive + dependency: "direct main" description: name: sqlite3 sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" @@ -1592,26 +1592,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.26.2" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.11" tflite_flutter: dependency: "direct main" description: diff --git a/walkguide-mobile/walkguide_app/pubspec.yaml b/walkguide-mobile/walkguide_app/pubspec.yaml index 7989aeb..2ab6e23 100644 --- a/walkguide-mobile/walkguide_app/pubspec.yaml +++ b/walkguide-mobile/walkguide_app/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: flutter_secure_storage: ^9.2.2 shared_preferences: ^2.3.2 drift: ^2.18.0 + sqlite3: ^2.4.7 sqlite3_flutter_libs: ^0.5.24 path_provider: ^2.1.3 path: ^1.9.0 diff --git a/walkguide-mobile/walkguide_app/test/integration_test/live_api_e2e_test.dart b/walkguide-mobile/walkguide_app/test/integration_test/live_api_e2e_test.dart new file mode 100644 index 0000000..5d3d5dd --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/integration_test/live_api_e2e_test.dart @@ -0,0 +1,123 @@ +// Live Spring Boot E2E smoke tests. +// +// Run on a physical Android device/profile build when the backend is online: +// flutter test test/integration_test/live_api_e2e_test.dart \ +// --dart-define=LIVE_API_BASE_URL=http://202.46.28.160:8080/api/v1 \ +// --dart-define=LIVE_USER_EMAIL=user@example.com \ +// --dart-define=LIVE_USER_PASSWORD=password \ +// --dart-define=LIVE_GUARDIAN_EMAIL=guardian@example.com \ +// --dart-define=LIVE_GUARDIAN_PASSWORD=password + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +const _baseUrl = String.fromEnvironment('LIVE_API_BASE_URL'); +const _userEmail = String.fromEnvironment('LIVE_USER_EMAIL'); +const _userPassword = String.fromEnvironment('LIVE_USER_PASSWORD'); +const _guardianEmail = String.fromEnvironment('LIVE_GUARDIAN_EMAIL'); +const _guardianPassword = String.fromEnvironment('LIVE_GUARDIAN_PASSWORD'); + +bool get _liveApiConfigured => + _baseUrl.isNotEmpty && + _userEmail.isNotEmpty && + _userPassword.isNotEmpty && + _guardianEmail.isNotEmpty && + _guardianPassword.isNotEmpty; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('WalkGuide live Spring Boot API E2E', () { + late Dio dio; + String? userToken; + String? guardianToken; + + setUpAll(() { + dio = Dio(BaseOptions( + baseUrl: _baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + validateStatus: (status) => status != null && status < 500, + )); + }); + + testWidgets('flow 1: ping, user login, profile', (tester) async { + if (!_liveApiConfigured) { + // Keep CI green when live credentials are not injected. + // The final benchmark run should pass LIVE_* dart-defines. + return; + } + + final ping = await dio.get('/auth/ping'); + expect(ping.statusCode, 200); + + final login = await dio.post('/auth/login', data: { + 'email': _userEmail, + 'password': _userPassword, + }); + expect(login.statusCode, 200); + userToken = login.data['data']['accessToken'] as String?; + expect(userToken, isNotNull); + + final profile = await dio.get( + '/user/profile', + options: Options(headers: {'Authorization': 'Bearer $userToken'}), + ); + expect(profile.statusCode, 200); + expect(profile.data['data']['email'], isNotEmpty); + }); + + testWidgets('flow 2: WalkGuide start, SOS, stop', (tester) async { + if (!_liveApiConfigured) { + // Keep CI green when live credentials are not injected. + // The final benchmark run should pass LIVE_* dart-defines. + return; + } + expect(userToken, isNotNull); + final auth = Options(headers: {'Authorization': 'Bearer $userToken'}); + + final start = await dio.post('/user/walkguide/start', options: auth); + expect(start.statusCode, 200); + + final sos = await dio.post('/user/sos', data: { + 'triggerType': 'MANUAL', + 'lat': null, + 'lng': null, + }, options: auth); + expect(sos.statusCode, 200); + + final stop = await dio.post('/user/walkguide/stop', options: auth); + expect(stop.statusCode, 200); + }); + + testWidgets('flow 3: guardian dashboard and user notifications', (tester) async { + if (!_liveApiConfigured) { + // Keep CI green when live credentials are not injected. + // The final benchmark run should pass LIVE_* dart-defines. + return; + } + + final guardianLogin = await dio.post('/auth/login', data: { + 'email': _guardianEmail, + 'password': _guardianPassword, + }); + expect(guardianLogin.statusCode, 200); + guardianToken = guardianLogin.data['data']['accessToken'] as String?; + expect(guardianToken, isNotNull); + + final guardianAuth = + Options(headers: {'Authorization': 'Bearer $guardianToken'}); + final dashboard = await dio.get('/guardian/dashboard', options: guardianAuth); + expect(dashboard.statusCode, 200); + + final userAuth = Options(headers: {'Authorization': 'Bearer $userToken'}); + final notifications = await dio.get('/user/notifications', options: userAuth); + expect(notifications.statusCode, 200); + + final markAll = + await dio.put('/user/notifications/mark-all-read', options: userAuth); + expect(markAll.statusCode, 200); + }); + }); +}