diff --git a/.gitignore b/.gitignore index f32668e..74c6ef0 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ build/ ### VS Code ### .vscode/ + +.env +*.env diff --git a/ooad-docs/diagrams/Class Diagram WalkGuide RILLL FIX RIL.drawio.pdf b/ooad-docs/diagrams/Class Diagram WalkGuide RILLL FIX RIL.drawio.pdf new file mode 100644 index 0000000..75d4bb7 Binary files /dev/null and b/ooad-docs/diagrams/Class Diagram WalkGuide RILLL FIX RIL.drawio.pdf differ diff --git a/ooad-docs/diagrams/Er Diagram WalkGuide RILL FIX.drawio.pdf b/ooad-docs/diagrams/Er Diagram WalkGuide RILL FIX.drawio.pdf new file mode 100644 index 0000000..8d2f5ef Binary files /dev/null and b/ooad-docs/diagrams/Er Diagram WalkGuide RILL FIX.drawio.pdf differ diff --git a/ooad-docs/diagrams/component-diagram.puml b/ooad-docs/diagrams/component-diagram.puml index c3d8914..8f744cd 100644 --- a/ooad-docs/diagrams/component-diagram.puml +++ b/ooad-docs/diagrams/component-diagram.puml @@ -1,35 +1,542 @@ -@startuml +@startuml WalkGuide_Component_Diagram +title Component Diagram — WalkGuide System (v2.0) + skinparam componentStyle rectangle - -component "Flutter Mobile App" { - [Presentation Screens] - [ApiClient + Interceptors] - [TTS/STT/YOLO/Location Services] - [WebSocketService] - [CallService] +skinparam backgroundColor #FAFAFA +skinparam component { + BackgroundColor #EEF2FF + BorderColor #6366F1 + FontColor #1E1B4B + FontSize 11 +} +skinparam package { + BackgroundColor #F0F4FF + BorderColor #818CF8 + FontColor #1E1B4B + FontSize 12 + FontStyle bold +} +skinparam interface { + BackgroundColor #DBEAFE + BorderColor #3B82F6 +} +skinparam database { + BackgroundColor #FEF3C7 + BorderColor #D97706 +} +skinparam cloud { + BackgroundColor #F0FDF4 + BorderColor #16A34A +} +skinparam arrow { + Color #4B5563 + FontSize 9 + FontColor #374151 +} +skinparam note { + BackgroundColor #FFFBEB + BorderColor #F59E0B + FontSize 9 } -component "Spring Boot Backend" { - [Controllers] - [Services] - [Repositories] - [Security/JWT] - [WebSocket Broker] +' ───────────────────────────────────────────── +' MOBILE DEVICE — Flutter App +' ───────────────────────────────────────────── +package "Mobile Device (Flutter App)" as FLUTTER #E8EAF6 { + + package "Presentation Layer" as PRESENTATION { + [ServerConnectScreen] as SCS + [SplashScreen] as SS + [LoginScreen] as LS + [RegisterScreen] as RS + + package "User Screens" as US_SCREENS { + [WalkGuideScreen] as WGS + [SosScreen] as SOSS + [ActivityLogScreen] as ALS + [NotificationScreen] as NS + [NavigationModeScreen] as NMS + [UserSettingsScreen] as USS + [CallScreen] as CS + [ManualScreen] as MS + [UserPairingScreen] as UPS + } + + package "Guardian Screens" as GS_SCREENS { + [GuardianDashboardScreen] as GDS + [GuardianMapScreen] as GMS + [GuardianActivityLogScreen] as GALS + [GuardianSendNotifScreen] as GSNS + [GuardianAiConfigScreen] as GACS + [GuardianVoiceCmdScreen] as GVCS + [GuardianShortcutScreen] as GSHS + [GuardianGeofenceScreen] as GGFS + [GuardianPairingScreen] as GPS + [IncomingCallScreen] as ICS + } + } + + package "Application Layer (BLoC / Cubit)" as BLOC_LAYER { + [AuthBloc] as AuthBloc + [PairingBloc] as PairingBloc + [WalkGuideBloc] as WGBloc + [SosBloc] as SosBloc + [LocationBloc] as LocBloc + [NotificationBloc] as NotifBloc + [ActivityLogBloc] as ActLogBloc + [VoiceCommandBloc] as VCBloc + [ServerConnectBloc] as SCBloc + [CallBloc] as CallBloc + [GuardianDashboardBloc] as GDBloc + } + + package "Domain Layer" as DOMAIN { + package "Use Cases" as USECASES { + [LoginUseCase] as LoginUC + [RegisterUseCase] as RegUC + [LogoutUseCase] as LogoutUC + [InviteUserUseCase] as InviteUC + [RespondPairingUseCase] as RespUC + [UnpairUseCase] as UnpairUC + [StartSessionUseCase] as StartUC + [StopSessionUseCase] as StopUC + [LogObstacleUseCase] as LogObsUC + [TriggerSosUseCase] as TrigSosUC + [UpdateLocationUseCase] as UpdLocUC + } + package "Repository Interfaces" as REPO_INTF { + interface "IAuthRepository" as IAuthRepo + interface "IPairingRepository" as IPairRepo + interface "IWalkGuideRepository" as IWGRepo + interface "IActivityLogRepository" as IActRepo + interface "ISosRepository" as ISosRepo + interface "ILocationRepository" as ILocRepo + interface "INotificationRepository" as INotifRepo + interface "IAiConfigRepository" as IAIRepo + } + } + + package "Data Layer" as DATA { + package "Repository Implementations" as REPO_IMPL { + [AuthRepositoryImpl] as AuthRepoImpl + [PairingRepositoryImpl] as PairRepoImpl + [WalkGuideRepositoryImpl] as WGRepoImpl + [ActivityLogRepositoryImpl] as ActRepoImpl + [SosRepositoryImpl] as SosRepoImpl + [LocationRepositoryImpl] as LocRepoImpl + [NotificationRepositoryImpl] as NotifRepoImpl + [AiConfigRepositoryImpl] as AIRepoImpl + } + + package "Remote Data Sources" as REMOTE_DS { + [AuthRemoteDataSource] as AuthDS + [WalkGuideRemoteDataSource] as WGDS + [ActivityLogRemoteDataSource] as ActDS + [SosRemoteDataSource] as SosDS + [LocationRemoteDataSource] as LocDS + [NotificationRemoteDataSource] as NotifDS + [GuardianRemoteDataSource] as GrdDS + } + + package "Local Data Sources (Drift/SQLite)" as LOCAL_DS { + [SecureStorageService] as SecStore + [LocalDatabase (Drift)] as DriftDB + [SharedPreferencesService] as SharedPref + } + + package "Core Services (Singletons)" as CORE_SVC { + [TtsService\n(flutter_tts)] as TTS + [SttService\n(speech_to_text)] as STT + [VoiceCommandHandler] as VCH + [HapticService\n(vibration)] as HAP + [HardwareShortcutListener] as HSL + [FcmService\n(firebase_messaging)] as FCMSvc + [WebSocketService\n(stomp_dart_client)] as WSSvc + [AgoraService\n(agora_rtc_engine)] as AgoraSvc + } + + package "AI Engine (On-Device)" as AI_ENGINE { + [ModelLoader\n(tflite_flutter)] as ModelLoader + [YoloDetector\n(YOLOv8n)] as YOLO + [ObstacleAnalyzer] as ObsAnalyzer + } + + [ApiClient\n(Dio + Interceptors)] as DioClient + } } -database "PostgreSQL" as DB -cloud "Firebase FCM" as FCM -cloud "Agora RTC" as Agora -cloud "OpenStreetMap/OSRM" as Maps +' ───────────────────────────────────────────── +' BACKEND SERVER — Spring Boot +' ───────────────────────────────────────────── +package "Backend Server (Spring Boot @ 202.46.28.160:8080)" as BACKEND #E8F5E9 { + + package "Security & Config" as SEC_CFG { + [SecurityConfig\n(CORS, CSRF, RBAC)] as SecConfig + [JwtAuthFilter\n(OncePerRequestFilter)] as JWTFilter + [JwtUtil\n(JJWT)] as JWTUtil + [CustomUserDetailsService] as CUDS + [WebSocketConfig\n(STOMP)] as WSConfig + [GlobalExceptionHandler\n(@ControllerAdvice)] as GEH + [SwaggerConfig\n(Springdoc OpenAPI)] as SwagConfig + } + + package "Controller Layer" as CTRL { + [AuthController\n/api/v1/auth] as AuthCtrl + [PairingController\n/api/v1/shared/pairing] as PairCtrl + [GuardianController\n/api/v1/guardian] as GrdCtrl + [UserController\n/api/v1/user] as UserCtrl + [CallController\n/api/v1/shared/call] as CallCtrl + } + + package "Service Layer" as SVC { + [AuthService] as AuthSvc + [PairingService] as PairSvc + [ActivityLogService] as ActLogSvc + [LocationService\n(+ Haversine)] as LocSvc + [ObstacleLogService] as ObsLogSvc + [NotificationService] as NotifSvc + [SosService] as SosSvc + [AiConfigService] as AICfgSvc + [VoiceCommandService] as VCSvc + [HardwareShortcutService] as HWSvc + [GeofenceService\n(Haversine)] as GeoSvc + [UserSettingsService] as UserSetSvc + [FcmService\n(Firebase Admin)] as FcmBackSvc + [AgoraTokenService] as AgoraSvcBE + [GuardianDashboardService] as GDSvc + } + + package "WebSocket Broadcaster" as WS_BROADCAST { + [LocationBroadcaster\n(SimpMessagingTemplate)] as LocBcast + } + + package "Repository Layer (Spring Data JPA)" as REPO { + [UserRepository] as UserRepo + [PairingRelationRepository] as PairRepo + [ActivityLogRepository] as ActRepo + [ObstacleLogRepository] as ObsRepo + [LocationHistoryRepository] as LocRepo + [GuardianNotificationRepository] as NotifRepo + [SosEventRepository] as SosRepo + [UserSettingsRepository] as UserSetRepo + [AiConfigRepository] as AIRepo + [VoiceCommandConfigRepository] as VCRepo + [HardwareShortcutRepository] as HWRepo + [GeofenceConfigRepository] as GeoRepo + [RefreshTokenRepository] as RTRepo + } + + package "Domain / Entity" as ENTITY { + [User] as UEnt + [PairingRelation] as PRel + [ActivityLog] as ActEnt + [ObstacleLog] as ObsEnt + [LocationHistory] as LocEnt + [GuardianNotification] as NotifEnt + [SosEvent] as SosEnt + [UserSettings] as UserSetEnt + [AiConfig] as AICfgEnt + [VoiceCommandConfig] as VCEnt + [HardwareShortcut] as HWEnt + [GeofenceConfig] as GeoEnt + [RefreshToken] as RTEnt + } +} + +' ───────────────────────────────────────────── +' EXTERNAL SERVICES +' ───────────────────────────────────────────── +package "External Services" as EXTERNAL #FFF8E1 { + cloud "Firebase (FCM)\nPush Notifications" as FCM + cloud "Agora RTC\nVoIP Audio Call" as AGORA + cloud "OpenStreetMap\n(flutter_map tiles)" as OSM +} + +database "PostgreSQL\n202.46.28.160:2002\n(University Server)\nFlyway Migrations V1-V16" as PGDB + +' ───────────────────────────────────────────── +' PRESENTATION → BLOC +' ───────────────────────────────────────────── +SCS --> SCBloc +SS --> AuthBloc +LS --> AuthBloc +RS --> AuthBloc +WGS --> WGBloc +WGS --> VCBloc +SOSS --> SosBloc +ALS --> ActLogBloc +NS --> NotifBloc +NMS --> LocBloc +USS --> AuthBloc +CS --> CallBloc +UPS --> PairingBloc +GDS --> GDBloc +GMS --> LocBloc +GALS --> ActLogBloc +GSNS --> NotifBloc +GACS --> AIRepoImpl +GVCS --> VCBloc +GSHS --> VCBloc +GGFS --> GDBloc +GPS --> PairingBloc +ICS --> CallBloc + +' ───────────────────────────────────────────── +' BLOC → USE CASES +' ───────────────────────────────────────────── +AuthBloc --> LoginUC +AuthBloc --> RegUC +AuthBloc --> LogoutUC +PairingBloc --> InviteUC +PairingBloc --> RespUC +PairingBloc --> UnpairUC +WGBloc --> StartUC +WGBloc --> StopUC +WGBloc --> LogObsUC +SosBloc --> TrigSosUC +LocBloc --> UpdLocUC +CallBloc --> AgoraSvc +VCBloc --> VCH + +' ───────────────────────────────────────────── +' USE CASES → REPOSITORY INTERFACES +' ───────────────────────────────────────────── +LoginUC --> IAuthRepo +RegUC --> IAuthRepo +LogoutUC --> IAuthRepo +InviteUC --> IPairRepo +RespUC --> IPairRepo +UnpairUC --> IPairRepo +StartUC --> IWGRepo +StopUC --> IWGRepo +LogObsUC --> IWGRepo +TrigSosUC --> ISosRepo +UpdLocUC --> ILocRepo + +' ───────────────────────────────────────────── +' INTERFACES → IMPLEMENTATIONS +' ───────────────────────────────────────────── +IAuthRepo <|.. AuthRepoImpl +IPairRepo <|.. PairRepoImpl +IWGRepo <|.. WGRepoImpl +IActRepo <|.. ActRepoImpl +ISosRepo <|.. SosRepoImpl +ILocRepo <|.. LocRepoImpl +INotifRepo <|.. NotifRepoImpl +IAIRepo <|.. AIRepoImpl + +' ───────────────────────────────────────────── +' REPO IMPL → DATA SOURCES +' ───────────────────────────────────────────── +AuthRepoImpl --> AuthDS +AuthRepoImpl --> SecStore +PairRepoImpl --> GrdDS +WGRepoImpl --> WGDS +WGRepoImpl --> DriftDB +ActRepoImpl --> ActDS +SosRepoImpl --> SosDS +LocRepoImpl --> LocDS +NotifRepoImpl --> NotifDS +NotifRepoImpl --> DriftDB +AIRepoImpl --> GrdDS + +' ───────────────────────────────────────────── +' REMOTE DATA SOURCES → DioClient +' ───────────────────────────────────────────── +AuthDS --> DioClient +WGDS --> DioClient +ActDS --> DioClient +SosDS --> DioClient +LocDS --> DioClient +NotifDS --> DioClient +GrdDS --> DioClient + +' ───────────────────────────────────────────── +' CORE SERVICES INTERCONNECT +' ───────────────────────────────────────────── +WGBloc --> YOLO +WGBloc --> TTS +WGBloc --> HAP +YOLO --> ModelLoader +YOLO --> ObsAnalyzer +ObsAnalyzer --> TTS +ObsAnalyzer --> HAP +VCH --> STT +VCH --> DriftDB +HSL --> DriftDB +FCMSvc --> DriftDB + +SharedPref --> DioClient : baseUrl + +' ───────────────────────────────────────────── +' DioClient → Backend REST API +' ───────────────────────────────────────────── +DioClient -down-> AuthCtrl : "POST /api/v1/auth/register\nPOST /api/v1/auth/login\nPOST /api/v1/auth/refresh\nPOST /api/v1/auth/logout\nPUT /api/v1/auth/fcm-token\nGET /api/v1/auth/ping" +DioClient -down-> PairCtrl : "POST /shared/pairing/invite\nPOST /shared/pairing/respond\nDELETE /shared/pairing/unpair\nGET /shared/pairing/status" +DioClient -down-> UserCtrl : "GET /user/profile\nPOST /user/location\nPOST /user/obstacle\nPOST /user/sos\nGET /user/notifications\nPOST /user/walkguide/start\nPOST /user/walkguide/stop" +DioClient -down-> GrdCtrl : "GET /guardian/dashboard\nGET /guardian/user-location\nPOST /guardian/notifications/send\nPUT /guardian/ai-config\nPUT /guardian/geofence" +DioClient -down-> CallCtrl : "POST /shared/call/token\nPOST /shared/call/notify" + +' WebSocket STOMP +WSSvc -down-> WSConfig : "ws://host:8080/ws\n(STOMP)\n/topic/location/{userId}\n/queue/sos/{guardianId}\n/queue/notif/{userId}" + +' ───────────────────────────────────────────── +' BACKEND SECURITY FLOW +' ───────────────────────────────────────────── +JWTFilter --> JWTUtil : validate token +JWTFilter --> CUDS : load user +JWTFilter -up-> SecConfig : filter chain +SecConfig -right-> JWTFilter + +' ───────────────────────────────────────────── +' CONTROLLERS → SERVICES +' ───────────────────────────────────────────── +AuthCtrl --> AuthSvc +PairCtrl --> PairSvc +GrdCtrl --> GDSvc +GrdCtrl --> LocSvc +GrdCtrl --> ActLogSvc +GrdCtrl --> ObsLogSvc +GrdCtrl --> NotifSvc +GrdCtrl --> SosSvc +GrdCtrl --> AICfgSvc +GrdCtrl --> VCSvc +GrdCtrl --> HWSvc +GrdCtrl --> GeoSvc +UserCtrl --> AuthSvc +UserCtrl --> LocSvc +UserCtrl --> ObsLogSvc +UserCtrl --> SosSvc +UserCtrl --> ActLogSvc +UserCtrl --> NotifSvc +UserCtrl --> UserSetSvc +UserCtrl --> VCSvc +UserCtrl --> HWSvc +UserCtrl --> AICfgSvc +CallCtrl --> AgoraSvcBE + +' ───────────────────────────────────────────── +' SERVICES CROSS-DEPENDENCIES +' ───────────────────────────────────────────── +AuthSvc --> PairSvc : seed defaults on register +PairSvc --> VCSvc : seedDefaults() +PairSvc --> HWSvc : seedDefaults() +PairSvc --> AICfgSvc : create config on pair +PairSvc --> FcmBackSvc : send FCM invite/response +NotifSvc --> FcmBackSvc : send FCM notification +SosSvc --> FcmBackSvc : send FCM SOS high-priority +AICfgSvc --> FcmBackSvc : notify settings updated +LocSvc --> GeoSvc : checkGeofence() +LocSvc --> LocBcast : broadcastLocation() +LocSvc --> ActLogSvc : log LOCATION_UPDATE +NotifSvc --> LocBcast : broadcastNotification() +SosSvc --> LocBcast : broadcastSos() +SosSvc --> ActLogSvc : log SOS_TRIGGERED +ActLogSvc --> LocBcast : broadcast to Guardian +GDSvc --> LocSvc +GDSvc --> ActLogSvc +GDSvc --> NotifSvc +GDSvc --> SosSvc + +' ───────────────────────────────────────────── +' SERVICES → REPOSITORIES +' ───────────────────────────────────────────── +AuthSvc --> UserRepo +AuthSvc --> RTRepo +AuthSvc --> ActLogSvc +PairSvc --> PairRepo +PairSvc --> UserRepo +ActLogSvc --> ActRepo +LocSvc --> LocRepo +LocSvc --> PairRepo +ObsLogSvc --> ObsRepo +ObsLogSvc --> ActLogSvc +NotifSvc --> NotifRepo +NotifSvc --> PairRepo +SosSvc --> SosRepo +SosSvc --> PairRepo +AICfgSvc --> AIRepo +VCSvc --> VCRepo +HWSvc --> HWRepo +GeoSvc --> GeoRepo +UserSetSvc --> UserSetRepo +FcmBackSvc --> UserRepo +AgoraSvcBE --> UserRepo + +' ───────────────────────────────────────────── +' REPOSITORIES → ENTITIES (JPA/Hibernate) +' ───────────────────────────────────────────── +UserRepo --> UEnt +PairRepo --> PRel +ActRepo --> ActEnt +ObsRepo --> ObsEnt +LocRepo --> LocEnt +NotifRepo --> NotifEnt +SosRepo --> SosEnt +UserSetRepo --> UserSetEnt +AIRepo --> AICfgEnt +VCRepo --> VCEnt +HWRepo --> HWEnt +GeoRepo --> GeoEnt +RTRepo --> RTEnt + +' ───────────────────────────────────────────── +' ENTITIES → DATABASE +' ───────────────────────────────────────────── +UEnt --> PGDB : JPA/Hibernate +PRel --> PGDB +ActEnt --> PGDB +ObsEnt --> PGDB +LocEnt --> PGDB +NotifEnt --> PGDB +SosEnt --> PGDB +UserSetEnt --> PGDB +AICfgEnt --> PGDB +VCEnt --> PGDB +HWEnt --> PGDB +GeoEnt --> PGDB +RTEnt --> PGDB + +' ───────────────────────────────────────────── +' EXTERNAL SERVICES +' ───────────────────────────────────────────── +FcmBackSvc -right-> FCM : "Firebase Admin SDK\nFirebaseMessaging.send()" +AgoraSvcBE -right-> AGORA : "Generate RTC Token\n(server-side Agora SDK)" +AgoraSvc --> AGORA : "agora_rtc_engine\nJoin Channel (audio-only)" +FCMSvc --> FCM : "firebase_messaging\nReceive push" +GMS --> OSM : "flutter_map\nOpenStreetMap tiles" + +note top of PGDB + Tables (Flyway V1–V16): + users, pairing_relations, + activity_logs, obstacle_logs, + location_history, guardian_notifications, + sos_events, user_settings, ai_configs, + voice_command_configs, hardware_shortcuts, + geofence_configs, refresh_tokens +end note + +note right of AI_ENGINE + On-Device AI (No server call): + YOLOv8n .tflite (640×640) + → detect obstacles + → direction: LEFT/CENTER/RIGHT + → TTS + Haptic alert + NNAPI delegate (Android AI accel.) +end note + +note left of SharedPref + Dynamic Server URL: + User inputs "http://202.46.28.160:8080" + Saved to SharedPreferences + No hardcoded BASE_URL in APK +end note + +note bottom of WSSvc + WebSocket STOMP: + /topic/location/{userId} → Guardian map real-time + /queue/sos/{guardianId} → SOS alert instant + /queue/notif/{userId} → Notification push +end note -[Presentation Screens] --> [ApiClient + Interceptors] -[ApiClient + Interceptors] --> [Controllers] : REST /api/v1 -[WebSocketService] --> [WebSocket Broker] : STOMP /ws -[CallService] --> [Controllers] : /shared/call -[Controllers] --> [Services] -[Services] --> [Repositories] -[Repositories] --> DB -[Services] --> FCM -[CallService] --> Agora -[Presentation Screens] --> Maps @enduml diff --git a/ooad-docs/diagrams/sequence-diagram.puml b/ooad-docs/diagrams/sequence-diagram.puml new file mode 100644 index 0000000..8f18b78 --- /dev/null +++ b/ooad-docs/diagrams/sequence-diagram.puml @@ -0,0 +1,381 @@ +@startuml +skinparam sequenceMessageAlign center +skinparam responseMessageBelowArrow true +skinparam maxMessageSize 150 +skinparam sequence { + ParticipantBackgroundColor LightBlue + ActorBackgroundColor LightGray + LifeLineBorderColor gray + GroupBorderColor DarkGray +} + +title WalkGuide System — Complete Sequence Diagram + +' ========================================== +' PARTICIPANTS +' ========================================== +actor "Tunanetra\n(ROLE_USER)" as User +actor "Pendamping\n(ROLE_GUARDIAN)" as Guardian +participant "Flutter App\n(User Side)" as AppUser +participant "Flutter App\n(Guardian Side)" as AppGuardian +participant "Spring Boot\nBackend" as Backend +participant "JWT Auth\nFilter" as JWT +participant "WebSocket\n(STOMP)" as WS +participant "Firebase FCM" as FCM +participant "Agora RTC" as Agora +participant "YOLOv8n\n(On-Device AI)" as YOLO + +' ============================================================ +' 1. SERVER CONNECT +' ============================================================ +== 1. Server Connect (Pertama Kali Install) == + +User -> AppUser : Buka aplikasi +AppUser -> AppUser : Cek SharedPreferences\n(serverUrl ada?) +alt serverUrl TIDAK ada + AppUser -> User : Tampilkan ServerConnectScreen + User -> AppUser : Input URL server\n(http://202.46.28.160:8080) + AppUser -> Backend : GET /api/v1/health (ping) + alt Server OK + Backend --> AppUser : 200 OK + AppUser -> AppUser : Simpan serverUrl\nke SharedPreferences + AppUser -> User : Redirect ke LoginScreen + else Server Gagal + Backend --> AppUser : Timeout / Error + AppUser -> User : Tampilkan pesan error\n"Server tidak dapat dijangkau" + end +else serverUrl sudah ada + AppUser -> User : Langsung ke SplashScreen +end + +' ============================================================ +' 2. AUTH — REGISTER +' ============================================================ +== 2. Register Akun == + +User -> AppUser : Isi form Register\n(email, password, role) +AppUser -> Backend : POST /api/v1/auth/register\n{email, password, displayName, role} +Backend -> Backend : Validasi @NotBlank, @Email +Backend -> Backend : Cek email unik +Backend -> Backend : BCrypt encode password +alt role = ROLE_USER + Backend -> Backend : Generate uniqueUserId\n(12 char alphanumeric) +end +Backend -> Backend : Simpan user ke DB +Backend -> Backend : Buat default UserSettings +Backend -> Backend : Generate JWT access token (1 jam)\n+ refresh token (30 hari) +Backend -> Backend : Simpan refresh token ke DB +Backend --> AppUser : 201 Created\n{accessToken, refreshToken, role, userId, uniqueUserId} +AppUser -> AppUser : Simpan token ke\nFlutterSecureStorage +AppUser -> Backend : PUT /api/v1/shared/fcm-token\n{fcmToken} +Backend --> AppUser : 200 OK +AppUser -> User : Redirect ke Home Screen\n(berdasarkan role) + +' ============================================================ +' 3. AUTH — LOGIN +' ============================================================ +== 3. Login == + +User -> AppUser : Input email & password +AppUser -> Backend : POST /api/v1/auth/login\n{email, password} +Backend -> Backend : findByEmail → validasi BCrypt +Backend -> Backend : Generate JWT access token\n+ refresh token +Backend -> Backend : Simpan refresh token ke DB +Backend -> Backend : Log ActivityLog: LOGIN +Backend --> AppUser : 200 OK\n{accessToken, refreshToken, role, userId} +AppUser -> AppUser : Simpan token ke\nFlutterSecureStorage +AppUser -> Backend : PUT /api/v1/shared/fcm-token\n{fcmToken} +Backend --> AppUser : 200 OK +AppUser -> WS : STOMP connect\n(Authorization: Bearer token) +WS --> AppUser : Connected +AppUser -> User : Redirect ke Home\n(ROLE_USER → /user/home) + +Guardian -> AppGuardian : Login +AppGuardian -> Backend : POST /api/v1/auth/login +Backend --> AppGuardian : 200 OK {accessToken, role=ROLE_GUARDIAN} +AppGuardian -> WS : STOMP connect +WS --> AppGuardian : Connected +AppGuardian -> Guardian : Redirect ke /guardian/home + +' ============================================================ +' 4. AUTH — JWT VALIDATE & REFRESH +' ============================================================ +== 4. JWT Validate & Auto-Refresh == + +AppUser -> Backend : Request API (any endpoint)\n[Authorization: Bearer accessToken] +Backend -> JWT : Validasi token +alt Token valid + JWT --> Backend : OK, userId + role + Backend -> Backend : RBAC check role + Backend --> AppUser : 200 Response +else Token expired (401) + JWT --> Backend : 401 Unauthorized + Backend --> AppUser : 401 Unauthorized + AppUser -> Backend : POST /api/v1/auth/refresh\n{refreshToken} + Backend -> Backend : Cek refresh token di DB\n+ cek expired + alt Refresh token valid + Backend -> Backend : Generate access token baru + Backend --> AppUser : 200 OK {accessToken baru} + AppUser -> AppUser : Update token di SecureStorage + AppUser -> Backend : Retry request original + Backend --> AppUser : 200 Response + else Refresh token expired + Backend --> AppUser : 401 Unauthorized + AppUser -> AppUser : Clear semua token + AppUser -> User : Redirect ke /login + end +end + +' ============================================================ +' 5. PAIRING +' ============================================================ +== 5. Pairing — Guardian Undang User == + +Guardian -> AppGuardian : Input uniqueUserId\n(12 char) milik User +AppGuardian -> Backend : POST /api/v1/guardian/pairing/invite\n{uniqueUserId} +Backend -> Backend : Cek User exist by uniqueUserId +Backend -> Backend : Cek belum ada pairing aktif +Backend -> Backend : Buat PairingRelation\n(status=PENDING) +Backend -> FCM : Kirim push notif ke User\n(type=PAIRING_INVITE) +FCM --> AppUser : Push notification diterima +AppUser -> AppUser : FCM Handler:\nrefresh PairingBloc +AppUser -> AppUser : TTS: "Anda mendapat\nundangan pairing" +Backend --> AppGuardian : 200 OK {pairingId, status=PENDING} + +User -> AppUser : Buka notif / PairingScreen +AppUser -> Backend : GET /api/v1/user/pairing/pending +Backend --> AppUser : 200 OK {pairingId, guardianName} +User -> AppUser : Tap "Terima" atau "Tolak" +AppUser -> Backend : POST /api/v1/user/pairing/respond\n{pairingId, accept: true/false} +Backend -> Backend : Update PairingRelation\n(status=ACTIVE / REJECTED) +alt Accept + Backend -> Backend : Seed default VoiceCommandConfigs\n& HardwareShortcuts untuk pasangan ini + Backend -> FCM : Kirim notif ke Guardian\n(type=PAIRING_RESPONSE, accepted=true) + FCM --> AppGuardian : Push notification + AppGuardian -> AppGuardian : TTS Guardian:\n"User menerima pairing Anda" + Backend --> AppUser : 200 OK {status=ACTIVE} + AppUser -> User : TTS: "Pairing berhasil" +else Reject + Backend -> FCM : Kirim notif ke Guardian\n(type=PAIRING_RESPONSE, accepted=false) + Backend --> AppUser : 200 OK {status=REJECTED} +end + +' ============================================================ +' 6. WALK GUIDE MODE +' ============================================================ +== 6. Aktifkan Walk Guide Mode == + +User -> AppUser : Tap tombol / ucap\n"Start Walkguide" +AppUser -> AppUser : VoiceCommandHandler:\nmatch trigger phrase +AppUser -> AppUser : WalkGuideBloc:\nadd(StartWalkGuide()) +AppUser -> AppUser : Request permission:\nkamera, lokasi, mikrofon +AppUser -> YOLO : loadModel()\n(yolov8n.tflite dari assets) +YOLO --> AppUser : Model loaded +AppUser -> AppUser : Aktifkan kamera stream +AppUser -> AppUser : Aktifkan GPS stream\n(interval 5 detik) +AppUser -> User : TTS: "Walk Guide aktif.\nDeteksi obstacle dimulai." + +loop Setiap frame kamera (max 5 FPS) + AppUser -> YOLO : detect(CameraImage frame) + YOLO -> YOLO : YUV420 → RGB → resize 640×640 + YOLO -> YOLO : Normalize → run inference + YOLO -> YOLO : Post-process: NMS,\nfilter confidence threshold + YOLO --> AppUser : List\n{label, confidence, boundingBox} + alt Ada obstacle terdeteksi + AppUser -> AppUser : ObstacleAnalyzer:\nanalyzeDirection() + estimateDistance() + AppUser -> AppUser : buildTtsMessage():\n"Caution! {label} {direction}. {distance}." + AppUser -> AppUser : ttsService.speakImmediate(message) + AppUser -> User : 🔊 Audio TTS obstacle alert + AppUser -> AppUser : hapticService.obstacle\n{VeryClose/Close/Medium}() + AppUser -> User : 📳 Vibrasi sesuai jarak + AppUser -> Backend : POST /api/v1/user/obstacle-log\n{label, confidence, direction, dist, lat, lng} + Backend --> AppUser : 200 OK + end +end + +' ============================================================ +' 7. KIRIM LOKASI GPS REAL-TIME +' ============================================================ +== 7. Kirim Lokasi GPS Real-time (WebSocket) == + +loop Setiap 5 detik (WalkGuide aktif) / 30 detik (idle) + AppUser -> AppUser : Geolocator:\ngetCurrentPosition() + AppUser -> WS : STOMP send\n/app/location/{userId}\n{lat, lng, accuracy, speed, heading} + WS -> Backend : Proses LocationUpdate + Backend -> Backend : Simpan ke location_history + WS -> AppGuardian : Broadcast ke\n/topic/location/{userId} + AppGuardian -> AppGuardian : Update marker\ndi flutter_map (real-time) + AppGuardian -> Guardian : 🗺️ Peta terupdate + + alt User keluar radius Geofence + Backend -> Backend : Haversine: cek jarak\nke center geofence + Backend -> FCM : Push notif ke Guardian\n(type=GEOFENCE_EXIT) + FCM --> AppGuardian : Notifikasi masuk + AppGuardian -> Guardian : "User telah keluar\ndari area aman!" + end +end + +' ============================================================ +' 8. SOS DARURAT +' ============================================================ +== 8. Kirim SOS Darurat == + +User -> AppUser : Ucap "Send SOS" /\ntap tombol SOS +AppUser -> AppUser : SosBloc: add(TriggerSos()) +AppUser -> AppUser : hapticService.sosTriggered()\n[0,1000,200,1000,200,1000] +AppUser -> AppUser : ttsService.speak:\n"SOS dikirim ke Guardian" +AppUser -> Backend : POST /api/v1/user/sos\n{triggerType, lat, lng} +Backend -> Backend : Simpan SosEvent\n(status=TRIGGERED) +Backend -> Backend : Log ActivityLog: SOS_TRIGGERED +Backend -> FCM : Push notif ke Guardian\n(type=SOS_ALERT, lat, lng) +FCM --> AppGuardian : Push notification SOS +AppGuardian -> AppGuardian : FCM Handler:\nnavigate ke SOS/IncomingCallScreen +AppGuardian -> Guardian : 🚨 Alert SOS dengan lokasi User +Backend --> AppUser : 200 OK {sosEventId, status=TRIGGERED} + +Guardian -> AppGuardian : Tap "Acknowledge" +AppGuardian -> Backend : PUT /api/v1/guardian/sos/{id}/acknowledge +Backend -> Backend : Update status=ACKNOWLEDGED\nsimpan acknowledged_at +Backend --> AppGuardian : 200 OK +AppGuardian -> Guardian : Tampilkan status\n"SOS Diakui" + +Guardian -> AppGuardian : Tap "Resolved" +AppGuardian -> Backend : PUT /api/v1/guardian/sos/{id}/resolve +Backend -> Backend : Update status=RESOLVED +Backend --> AppGuardian : 200 OK + +' ============================================================ +' 9. VoIP CALL (AGORA) +' ============================================================ +== 9. Panggil Guardian (VoIP Agora) == + +User -> AppUser : Ucap "Call Guardian" /\ntap tombol Call +AppUser -> Backend : POST /api/v1/shared/agora/token\n{channelName, uid} +Backend -> Agora : Generate Agora RTC token\n(server-side REST API) +Agora --> Backend : {token, channelName, uid, appId} +Backend --> AppUser : 200 OK {AgoraTokenResponse} +AppUser -> FCM : (via Backend) Push notif ke Guardian\n(type=INCOMING_CALL, channelName) +FCM --> AppGuardian : Push notification panggilan masuk +AppGuardian -> AppGuardian : Navigate ke IncomingCallScreen +AppGuardian -> Guardian : 📞 UI Panggilan Masuk\n(haptic + ringtone) +AppUser -> Agora : joinChannel(token, channelName, uid)\n(audio-only, speakerphone ON) + +Guardian -> AppGuardian : Tap "Angkat" +AppGuardian -> Backend : POST /api/v1/shared/agora/token\n{channelName} +Backend --> AppGuardian : {AgoraTokenResponse} +AppGuardian -> Agora : joinChannel(token, channelName) +Agora --> AppUser : onUserJoined callback +Agora --> AppGuardian : onUserJoined callback +AppUser <-> AppGuardian : 🎙️ Audio call berlangsung\n(via Agora RTC) + +alt Guardian tutup telepon + Guardian -> AppGuardian : Tap "Tutup" + AppGuardian -> Agora : leaveChannel() + Agora --> AppUser : onUserOffline callback + AppUser -> Agora : leaveChannel() + AppUser -> User : TTS: "Panggilan berakhir" +end + +' ============================================================ +' 10. NOTIFIKASI GUARDIAN → USER +' ============================================================ +== 10. Kirim Notifikasi (Teks / Voice Note) == + +Guardian -> AppGuardian : Buka SendNotifScreen\nTulis pesan / rekam voice note +alt Notifikasi Teks + AppGuardian -> Backend : POST /api/v1/guardian/notification\n{notifType=TEXT, content} +else Voice Note + AppGuardian -> AppGuardian : record package:\nrekam audio + AppGuardian -> Backend : POST /api/v1/guardian/notification\n{notifType=VOICE_NOTE,\nvoiceNoteUrl, voiceNoteDuration} +end +Backend -> Backend : Simpan ke guardian_notifications +Backend -> FCM : Kirim push notif ke User\n(type=NOTIFICATION) +FCM --> AppUser : Push notification diterima +AppUser -> AppUser : FCM Handler: type=NOTIFICATION\n→ refresh NotificationBloc +AppUser -> AppUser : flutter_local_notifications:\ntampilkan notif lokal +AppUser -> AppUser : TTS: "Ada pesan baru\ndari Guardian" +AppUser -> User : 🔊 TTS pemberitahuan +Backend --> AppGuardian : 200 OK + +User -> AppUser : Buka NotificationScreen\natau ucap "Read All My Notifications" +AppUser -> Backend : GET /api/v1/user/notifications +Backend --> AppUser : 200 OK List +AppUser -> User : Tampilkan daftar notifikasi +alt Voice Note + User -> AppUser : Tap notifikasi voice note + AppUser -> AppUser : just_audio: play voiceNoteUrl + AppUser -> User : 🔊 Putar voice note Guardian +end +AppUser -> Backend : PUT /api/v1/user/notifications/read-all +Backend -> Backend : Update is_read=true semua notif +Backend --> AppUser : 200 OK + +' ============================================================ +' 11. GUARDIAN DASHBOARD +' ============================================================ +== 11. Guardian Dashboard — Monitor & Konfigurasi == + +Guardian -> AppGuardian : Buka GuardianDashboardScreen +AppGuardian -> Backend : GET /api/v1/guardian/paired-user +Backend --> AppGuardian : 200 OK {UserProfileResponse} +AppGuardian -> Backend : GET /api/v1/guardian/location/latest +Backend --> AppGuardian : 200 OK {lat, lng, speed, heading} +AppGuardian -> AppGuardian : flutter_map: tampilkan\nmarker lokasi User +AppGuardian -> WS : Subscribe /topic/location/{userId} +AppGuardian -> Guardian : 🗺️ Dashboard dengan peta real-time + +Guardian -> AppGuardian : Buka GuardianAiConfigScreen +AppGuardian -> Backend : GET /api/v1/guardian/ai-config +Backend --> AppGuardian : 200 OK {AiConfigResponse} +Guardian -> AppGuardian : Ubah threshold confidence,\ndistance, FPS, enabled labels +AppGuardian -> Backend : PUT /api/v1/guardian/ai-config\n{AiConfigUpdateRequest} +Backend -> Backend : Update ai_configs di DB +Backend -> FCM : Push notif ke User\n(type=SETTINGS_UPDATED) +FCM --> AppUser : Settings update diterima +AppUser -> Backend : GET /api/v1/user/ai-config +Backend --> AppUser : 200 OK {AiConfigResponse terbaru} +AppUser -> YOLO : Update confidenceThreshold\n& maxInferenceFps +Backend --> AppGuardian : 200 OK + +Guardian -> AppGuardian : Buka GuardianGeofenceScreen +AppGuardian -> Backend : GET /api/v1/guardian/geofence +Backend --> AppGuardian : 200 OK {GeofenceResponse} +Guardian -> AppGuardian : Set center (tap peta),\nradius, enable +AppGuardian -> Backend : PUT /api/v1/guardian/geofence\n{centerLat, centerLng, radiusMeters, enabled} +Backend -> Backend : Update geofence_configs +Backend --> AppGuardian : 200 OK +AppGuardian -> Guardian : Tampilkan lingkaran\ngeofence di peta + +Guardian -> AppGuardian : Buka GuardianVoiceCmdScreen +AppGuardian -> Backend : GET /api/v1/guardian/voice-commands +Backend --> AppGuardian : 200 OK List +Guardian -> AppGuardian : Ubah trigger phrase\n(misal: "mulai jalan") +AppGuardian -> Backend : PUT /api/v1/guardian/voice-commands\n{commandKey, triggerPhrase, enabled} +Backend -> Backend : Update voice_command_configs +Backend -> FCM : Push notif ke User\n(type=SETTINGS_UPDATED) +FCM --> AppUser : AppUser reload CachedVoiceCommands\nke SQLite lokal +Backend --> AppGuardian : 200 OK + +Guardian -> AppGuardian : Buka GuardianActivityLogScreen +AppGuardian -> Backend : GET /api/v1/guardian/activity-logs?page=0&size=20 +Backend --> AppGuardian : 200 OK Page +AppGuardian -> Backend : GET /api/v1/guardian/obstacle-logs?page=0&size=20 +Backend --> AppGuardian : 200 OK Page +AppGuardian -> Guardian : Tampilkan riwayat\naktivitas & deteksi obstacle + +' ============================================================ +' 12. LOGOUT +' ============================================================ +== 12. Logout == + +User -> AppUser : Tap Logout +AppUser -> Backend : POST /api/v1/auth/logout +Backend -> Backend : Delete refresh token dari DB +Backend -> Backend : Log ActivityLog: LOGOUT +Backend --> AppUser : 200 OK +AppUser -> WS : disconnect() +AppUser -> AppUser : SecureStorage.clearAll() +AppUser -> AppUser : SharedPreferences: hapus token +AppUser -> User : Redirect ke /login + +@enduml \ No newline at end of file diff --git a/ooad-docs/diagrams/state-machine.puml b/ooad-docs/diagrams/state-machine.puml new file mode 100644 index 0000000..6394644 --- /dev/null +++ b/ooad-docs/diagrams/state-machine.puml @@ -0,0 +1,470 @@ +@startuml WalkGuide_StateMachine +title State Machine Diagram — WalkGuide System (Full Architecture) + +skinparam state { + BackgroundColor WhiteSmoke + BorderColor #5B6AB0 + FontColor Black + ArrowColor #5B6AB0 + StartColor Black + EndColor Black +} +skinparam note { + BackgroundColor LightYellow + BorderColor #999 +} + +' ============================================================ +' TOP LEVEL: APP LAUNCH +' ============================================================ +[*] --> AppLaunched : App dibuka + +state AppLaunched { + [*] --> CheckingServerUrl + + CheckingServerUrl : Cek SharedPreferences\nserver URL tersimpan? + + CheckingServerUrl --> ServerConnectScreen : [Tidak ada URL] + CheckingServerUrl --> CheckingToken : [URL tersedia] + + ServerConnectScreen : Input server URL\n(http://202.46.28.160:8080) + ServerConnectScreen --> PingingServer : Submit URL + + PingingServer : GET /api/v1/auth/ping\n(cek koneksi ke backend) + PingingServer --> ServerConnectScreen : [Ping Gagal / Timeout] + PingingServer --> CheckingToken : [Ping OK → simpan URL ke SharedPreferences] + + CheckingToken : Cek flutter_secure_storage\naccessToken ada? + CheckingToken --> Unauthenticated : [Token Tidak Ada] + CheckingToken --> ValidatingToken : [Token Ada] + + ValidatingToken : Verifikasi JWT\n(decode expiry lokal) + ValidatingToken --> Unauthenticated : [Token Expired / Invalid] + ValidatingToken --> Authenticated : [Token Valid → decode role] +} + +' ============================================================ +' UNAUTHENTICATED +' ============================================================ +state Unauthenticated { + [*] --> LoginScreen + + LoginScreen : Input email & password\n+ tombol ke RegisterScreen + + LoginScreen --> RegisterScreen : Tap "Register" + RegisterScreen --> LoginScreen : Tap "Back to Login" + + RegisterScreen : Pilih role (ROLE_GUARDIAN / ROLE_USER)\nIsi email, password, displayName + + LoginScreen --> ValidatingCredentials : Submit login + RegisterScreen --> RegisteringUser : Submit register + + ValidatingCredentials : POST /api/v1/auth/login\n{email, password} + ValidatingCredentials --> LoginScreen : [401 Unauthorized → tampil error] + ValidatingCredentials --> StoringAuthData : [200 OK + JWT] + + RegisteringUser : POST /api/v1/auth/register\n{email, password, role, displayName} + RegisteringUser --> LoginScreen : [400 / Email sudah terdaftar] + RegisteringUser --> StoringAuthData : [201 Created + JWT] + + StoringAuthData : Simpan accessToken + refreshToken\nke flutter_secure_storage\nUpdate FCM token → PUT /auth/fcm-token\nWebSocketService.connect(accessToken) + StoringAuthData --> DecodingRole : JWT tersimpan + + DecodingRole : Ekstrak role dari JWT claims + DecodingRole --> [*] +} + +note on link + Login Sukses + 200 OK + JWT + (access + refresh) +end note + +Unauthenticated --> Authenticated : [Login / Register Sukses → role decoded] + +' ============================================================ +' AUTHENTICATED (role router) +' ============================================================ +state Authenticated { + [*] --> CheckingPairing + + CheckingPairing : GET /shared/pairing/status\ncek status pairing user ini + + CheckingPairing --> UserShell : [Role = ROLE_USER] + CheckingPairing --> GuardianShell : [Role = ROLE_GUARDIAN] +} + +' ============================================================ +' USER SHELL +' ============================================================ +state UserShell { + + [*] --> UserIdle + + UserIdle : Bottom nav 6 tabs aktif\nSTT always-listening di background\nWebSocket subscribe /queue/notif/{userId} + note right of UserIdle + Voice commands aktif:\n"Start Walkguide", "Open SOS",\n"Call Guardian", "Where Am I", dll + end note + + ' --- PAIRING CHECK --- + UserIdle --> PairingScreen_User : [Belum paired → TTS peringatan] + + state PairingScreen_User { + [*] --> WaitingPairingInvite + WaitingPairingInvite : GET /shared/pairing/status\nTampil uniqueUserId 12-char\n"Share this ID with your Guardian" + WaitingPairingInvite --> ReceivingInvite : FCM: PAIRING_INVITE diterima + ReceivingInvite --> WaitingPairingInvite : Tap "Reject" → POST /pairing/respond {accept:false} + ReceivingInvite --> PairingAccepted : Tap "Accept" → POST /pairing/respond {accept:true} + PairingAccepted : Backend seed 14 VoiceCommands\n+ 5 Shortcuts + AiConfig defaults\nFCM ke Guardian: "Pairing accepted"\nTTS: "Pairing successful. Guardian connected." + PairingAccepted --> [*] + } + + PairingScreen_User --> UserIdle : Pairing berhasil / lewati + + ' --- WALKGUIDE --- + UserIdle --> WalkGuideActive : Tap "Start Walk Guide"\nATAU voice "Start Walkguide"\nATAU Volume Down button + + state WalkGuideActive { + [*] --> InitializingCamera + + InitializingCamera : camera.startImageStream()\nRequest permissions (kamera, lokasi)\nPOST /user/walkguide/start → ActivityLog: WALKGUIDE_START + InitializingCamera --> [*] : [Permission Denied → kembali ke UserIdle] + + InitializingCamera --> DetectionLoop : Kamera siap\nTTS: "3... 2... 1... WalkGuide started." + + state DetectionLoop { + [*] --> CapturingFrame + + CapturingFrame : Terima CameraImage (YUV420)\nThrottle: maxInferenceFps (default 5fps) + CapturingFrame --> ProcessingFrame : [YoloDetector tidak sedang running → kirim frame] + CapturingFrame --> CapturingFrame : [YoloDetector sedang running → skip frame ini] + + ProcessingFrame : YUV420→RGB→Resize 640×640\nNormalize 0.0-1.0\ninterpreter.run() [Isolate terpisah]\nPost-process + NMS\nFilter by confidenceThreshold + + ProcessingFrame --> EvaluatingResult : Inference selesai + + EvaluatingResult : ObstacleAnalyzer.prioritize(results)\nTentukan direction (LEFT/CENTER/RIGHT)\nEstimasi distance (Very Close/Close/Medium/Far) + + EvaluatingResult --> ObstacleDetected : [Confidence ≥ threshold & ada deteksi] + EvaluatingResult --> NoObstacleDetected : [Confidence < threshold / tidak ada deteksi] + + ObstacleDetected : Build TTS: "Caution! {label} {direction}. {distance}. Please stop."\nttsService.speakImmediate()\nhapticService.obstacleVeryClose()\nDebounce 3 detik (jangan log yang sama terus)\nPOST /user/obstacle {label, confidence, direction, dist, lat, lng} + + NoObstacleDetected : Lanjut deteksi + + ObstacleDetected --> CapturingFrame : Feedback selesai + NoObstacleDetected --> CapturingFrame : Lanjut deteksi + + ' Location update loop (parallel, setiap 5 detik) + CapturingFrame --> SendingLocation : [Setiap 5 detik] + SendingLocation : geolocator.getCurrentPosition()\nPOST /user/location {lat, lng, accuracy, speed, heading}\nBackend: simpan LocationHistory\nWebSocket broadcast → Guardian map\nGeofenceService.checkGeofence() + SendingLocation --> CapturingFrame : Location terkirim + } + + DetectionLoop --> SavingSession : Tap "Stop Walk Guide"\nATAU voice "Stop Walkguide" + + SavingSession : camera.stopImageStream()\nPOST /user/walkguide/stop → ActivityLog: WALKGUIDE_STOP\nLocation interval → 30 detik + SavingSession --> [*] + } + + WalkGuideActive --> UserIdle : Sesi WalkGuide selesai + + ' --- SOS --- + UserIdle --> SosFlow : Tap SOS tab\nATAU voice "Send SOS"\nATAU hardware shortcut + + state SosFlow { + [*] --> SosConfirm + SosConfirm : SosScreen: tombol SOS merah besar\nTTS: "SOS screen. Press the big red button\nor say 'Send SOS' to alert your Guardian." + SosConfirm --> TriggeringSos : Tap SOS button / voice "Send SOS" + TriggeringSos : Get GPS position\nPOST /user/sos {triggerType, lat, lng}\nBackend: save SosEvent TRIGGERED\nFCM HIGH PRIORITY ke Guardian: "🚨 SOS ALERT!"\nWebSocket → /queue/sos/{guardianId}\nTTS: "SOS sent. Guardian has been alerted."\nhapticService.sosTriggered() + TriggeringSos --> WaitingAcknowledge : SOS terkirim + WaitingAcknowledge : Tunggu respons Guardian\nTampil status "Waiting for Guardian..." + WaitingAcknowledge --> SosAcknowledged : FCM: Guardian acknowledge SOS\nTTS: "Your Guardian has acknowledged.\nHelp is coming." + SosAcknowledged --> [*] + } + + SosFlow --> UserIdle : Kembali ke home + + ' --- NOTIFICATIONS --- + UserIdle --> NotificationFlow : Tap Notifications tab\nATAU voice "Open Notifications"\nATAU FCM/WebSocket masuk + + state NotificationFlow { + [*] --> ViewingNotifications + ViewingNotifications : GET /user/notifications\nGET /user/notifications/unread-count\nTampil list: TEXT / VOICE_NOTE\nBadge unread count\nTTS: "Notifications. You have {N} unread messages." + ViewingNotifications --> ReadingAllTts : Tap "Read All" / voice "Read All My Notifications" + ViewingNotifications --> ReadingOneTts : Tap satu notifikasi + ReadingAllTts : Iterate unread list:\n- TEXT: TTS "From Guardian on {date}: {content}"\n- VOICE_NOTE: just_audio.play(voiceNoteUrl)\nPUT /user/notifications/{id}/read setelah selesai + ReadingOneTts : TTS / play satu notifikasi\nPUT /user/notifications/{id}/read + ReadingAllTts --> ViewingNotifications : Semua notif dibaca + ReadingOneTts --> ViewingNotifications : Selesai + ViewingNotifications --> MarkingAllRead : Tap "Mark All Read"\nPUT /user/notifications/mark-all-read + MarkingAllRead --> ViewingNotifications : Done + } + + NotificationFlow --> UserIdle : Kembali + + ' --- NAVIGATION MODE --- + UserIdle --> NavigationModeActive : Tap Navigate tab\nATAU voice "Open Navigation" + + state NavigationModeActive { + [*] --> NavigationIdle + NavigationIdle : FlutterMap (OpenStreetMap tiles)\nTTS: "Navigation mode. Say a destination." + NavigationIdle --> SearchingDestination : Tap search bar / voice "Navigate to {place}" + SearchingDestination : OSM Nominatim API\nGET nominatim.openstreetmap.org/search?q=... + SearchingDestination --> NavigatingRoute : Lokasi ditemukan → OSRM routing + NavigatingRoute : OSRM turn-by-turn route\nTTS setiap instruksi: "In 50 meters, turn right"\ngeolocator: GPS real-time\nPOST /user/location setiap 5 detik + NavigatingRoute --> NavigationIdle : Sampai tujuan / Stop + } + + NavigationModeActive --> UserIdle : Kembali + + ' --- CALL --- + UserIdle --> OutgoingCallFlow : Tap "Call Guardian"\nATAU voice "Call Guardian"\nATAU Volume Up button + + state OutgoingCallFlow { + [*] --> RequestingAgoraToken + RequestingAgoraToken : POST /shared/call/token\nGenerate Agora RTC token\nPOST /shared/call/notify → FCM ke Guardian "Incoming Call" + RequestingAgoraToken --> CallingGuardian : Token dapat, join Agora channel + CallingGuardian : CallScreen: "Calling Guardian..." + animasi\nAgoraRtcEngine.joinChannel() + CallingGuardian --> InCall : Guardian pick up → CallConnected + CallingGuardian --> UserIdle : Guardian decline / timeout 30 detik + InCall : Timer durasi call\nTombol: Mute, Speaker, End Call\nTTS: "Connected to Guardian" + InCall --> UserIdle : Tap End Call + } + + UserIdle --> IncomingCallHandled : FCM INCOMING_CALL diterima dari Guardian + + state IncomingCallHandled { + [*] --> ShowingIncomingCall + ShowingIncomingCall : IncomingCallScreen\nTTS: "Incoming call from Guardian"\nHaptic vibration\nTombol Accept (hijau) + Decline (merah)\nAuto-answer setelah 30 detik + ShowingIncomingCall --> InCallSession : Accept + ShowingIncomingCall --> UserIdle : Decline + InCallSession : Agora RTC connected\nTimer + Mute + Speaker + End + InCallSession --> UserIdle : End call + } + + ' --- SETTINGS --- + UserIdle --> UserSettingsFlow : Tap Settings tab\nATAU voice "Open Settings" + + state UserSettingsFlow { + [*] --> ViewingSettings + ViewingSettings : TTS Settings (read-only pitch/speed)\nPairing status link\nManual/Instructions link\nNo-Guardian Warning toggle\nAccount info + Logout + ViewingSettings --> UpdatingTtsLanguage : Toggle ID/EN → PUT /user/settings + ViewingSettings --> ViewingManual : Tap "Instructions" + ViewingSettings --> LoggingOut : Tap Logout + UpdatingTtsLanguage --> ViewingSettings : Saved + ViewingManual : ManualScreen\nSemua voice commands + shortcuts\nTombol "Hear Instructions"\nTTS setiap instruksi 1-per-1 + ViewingManual --> ViewingSettings : Back + LoggingOut : POST /auth/logout {refreshToken}\nSecureStorage.clearAll()\nWebSocketService.disconnect()\nGoRouter → /login + LoggingOut --> [*] + } + + UserSettingsFlow --> UserIdle : Kembali + UserSettingsFlow --> [*] : Logout + + ' --- ACTIVITY LOG --- + UserIdle --> ActivityLogFlow : Tap Activity tab\nATAU voice "Open Activity Log" + + state ActivityLogFlow { + [*] --> ViewingActivityLog + ViewingActivityLog : GET /user/activity-logs (paginated)\nInfinite scroll\nFilter by type/date\nTTS: "Activity log. {N} activities today." + } + + ActivityLogFlow --> UserIdle : Kembali +} + +' ============================================================ +' GUARDIAN SHELL +' ============================================================ +state GuardianShell { + + [*] --> GuardianIdle + + GuardianIdle : GuardianDashboardScreen HOME\nCard: User status (online/offline, battery)\nCard: Last location (Nominatim reverse)\nCard: Unread SOS (badge merah)\nCard: Recent 5 activity logs\nQuick actions: Send Message, Call User, View Map\nWebSocket subscribe:\n /topic/location/{pairedUserId}\n /queue/sos/{guardianId} + + ' --- PAIRING (Guardian side) --- + GuardianIdle --> GuardianPairingFlow : Tap "Manage Pairing"\nATAU belum punya paired user + + state GuardianPairingFlow { + [*] --> EnteringUserId + EnteringUserId : Input uniqueUserId 12-char\nmilik paired User + EnteringUserId --> SendingInvite : Tap "Send Invite" + SendingInvite : POST /shared/pairing/invite {uniqueUserId}\nBackend: buat PairingRelation PENDING\nFCM ke User: "Pairing Request" + SendingInvite --> WaitingUserResponse : Invite terkirim + WaitingUserResponse : Tunggu User accept/reject + WaitingUserResponse --> PairingSuccess : FCM: User accepted\nBackend seed VoiceCommands + Shortcuts + AiConfig + WaitingUserResponse --> EnteringUserId : FCM: User rejected + PairingSuccess : TTS: "Pairing successful. User is now connected."\nNavigate → GuardianIdle + PairingSuccess --> [*] + } + + GuardianPairingFlow --> GuardianIdle : Selesai + + ' --- MAP / LIVE TRACKING --- + GuardianIdle --> GuardianMapView : Tap "View Map"\nATAU quick action map + + state GuardianMapView { + [*] --> ViewingLiveMap + ViewingLiveMap : FlutterMap + live location marker\nWebSocket /topic/location/{userId}\nGeofence circle overlay (jika enabled)\nRoute history polyline (location_history) + ViewingLiveMap --> ViewingLiveMap : [WebSocket update → marker pindah] + } + + GuardianMapView --> GuardianIdle : Kembali + + ' --- ACTIVITY LOGS --- + GuardianIdle --> GuardianActivityLog : Tap "View Activity Logs" + + state GuardianActivityLog { + [*] --> ViewingUserLogs + ViewingUserLogs : GET /guardian/activity-logs (paginated)\nGET /guardian/obstacle-logs\nFilter by type, date + } + + GuardianActivityLog --> GuardianIdle : Kembali + + ' --- SEND NOTIFICATION --- + GuardianIdle --> GuardianSendNotif : Tap "Send Notification" + + state GuardianSendNotif { + [*] --> ComposingNotif + ComposingNotif : Toggle: TEXT / VOICE NOTE + ComposingNotif --> SendingTextNotif : Ketik pesan → Send + ComposingNotif --> RecordingVoiceNote : Tahan tombol Record + SendingTextNotif : POST /guardian/notifications/send\n{type: TEXT, content}\nBackend: FCM ke User + WebSocket\nUser TTS: "New message from Guardian" + RecordingVoiceNote : record package → audio file\nPreview → Upload → POST send\n{type: VOICE_NOTE, voiceNoteUrl} + SendingTextNotif --> ComposingNotif : Sent + RecordingVoiceNote --> ComposingNotif : Sent + } + + GuardianSendNotif --> GuardianIdle : Kembali + + ' --- AI CONFIG --- + GuardianIdle --> GuardianAiConfig : Tap "Configure AI Settings" + + state GuardianAiConfig { + [*] --> EditingAiConfig + EditingAiConfig : GET /guardian/ai-config\nSlider confidenceThreshold (0.3-0.9)\nSlider alertDistanceClose (0.5-3m)\nSlider alertDistanceMedium (1-6m)\nDropdown maxInferenceFps (1,2,3,5,10)\nMulti-select enabled labels + EditingAiConfig --> SavingAiConfig : Tap Save + SavingAiConfig : PUT /guardian/ai-config\nBackend: update + FCM ke User\n"Guardian updated AI settings" + SavingAiConfig --> EditingAiConfig : Saved + } + + GuardianAiConfig --> GuardianIdle : Kembali + + ' --- VOICE COMMANDS CONFIG --- + GuardianIdle --> GuardianVoiceCmdConfig : Tap "Voice Commands" + + state GuardianVoiceCmdConfig { + [*] --> EditingVoiceCommands + EditingVoiceCommands : GET /guardian/voice-commands\nList 14 commands\nEdit trigger phrase\nToggle enabled/disabled + EditingVoiceCommands --> SavingVoiceCmd : Save + SavingVoiceCmd : PUT /guardian/voice-commands\nFCM ke User: "Voice commands updated" + SavingVoiceCmd --> EditingVoiceCommands : Saved + } + + GuardianVoiceCmdConfig --> GuardianIdle : Kembali + + ' --- HARDWARE SHORTCUTS --- + GuardianIdle --> GuardianShortcutConfig : Tap "Shortcuts" + + state GuardianShortcutConfig { + [*] --> EditingShortcuts + EditingShortcuts : GET /guardian/shortcuts\nList 5 shortcuts (Vol Up, Vol Down, dll)\n"Unassigned" jika belum di-set + EditingShortcuts --> CapturingButton : Tap "Configure" per shortcut + CapturingButton : FCM ke User's phone "enter capture mode"\nUser tekan tombol fisik → keyCode\nPUT /user/shortcuts {shortcutKey, buttonCode} + CapturingButton --> EditingShortcuts : Button captured & saved + } + + GuardianShortcutConfig --> GuardianIdle : Kembali + + ' --- GEOFENCE --- + GuardianIdle --> GuardianGeofenceConfig : Tap "Geofence" + + state GuardianGeofenceConfig { + [*] --> EditingGeofence + EditingGeofence : FlutterMap: tap untuk set center\nSlider radius (50m - 5000m)\nToggle enable/disable geofence + EditingGeofence --> SavingGeofence : Tap Save + SavingGeofence : PUT /guardian/geofence\nBackend: simpan GeofenceConfig + SavingGeofence --> EditingGeofence : Saved + } + + GuardianGeofenceConfig --> GuardianIdle : Kembali + + ' --- SOS HANDLING --- + GuardianIdle --> SosAlertReceived : FCM HIGH PRIORITY / WebSocket SOS alert\n/queue/sos/{guardianId} + + state SosAlertReceived { + [*] --> DisplayingSosAlert + DisplayingSosAlert : GuardianDashboard: highlight merah\nMap otomatis buka ke lokasi User\nTampil: "🚨 {User} needs help! Location: lat,lng"\nGET /guardian/sos-events + DisplayingSosAlert --> AcknowledgingSos : Tap "Acknowledge" + AcknowledgingSos : PUT /guardian/sos/{id}/acknowledge\nBackend: status → ACKNOWLEDGED\nFCM ke User: "Guardian is on the way"\nUser TTS: "Help is coming." + AcknowledgingSos --> [*] + } + + SosAlertReceived --> GuardianIdle : Selesai + + ' --- CALL (Guardian inisiasi) --- + GuardianIdle --> GuardianOutgoingCall : Tap "Call User" + + state GuardianOutgoingCall { + [*] --> GuardianRequestToken + GuardianRequestToken : POST /shared/call/token\nPOST /shared/call/notify → FCM ke User "Incoming Call" + GuardianRequestToken --> GuardianCalling : Join Agora channel + GuardianCalling : "Calling User..." + ring animasi + GuardianCalling --> GuardianInCall : User accept + GuardianCalling --> GuardianIdle : User decline / timeout + GuardianInCall : Audio call aktif\nMute + Speaker + End + GuardianInCall --> GuardianIdle : End call + } + + GuardianOutgoingCall --> GuardianIdle : Selesai + + ' --- INCOMING CALL (Guardian terima) --- + GuardianIdle --> GuardianIncomingCall : FCM INCOMING_CALL dari User + + state GuardianIncomingCall { + [*] --> GuardianShowIncomingCall + GuardianShowIncomingCall : IncomingCallScreen\nTTS: "Incoming call from User"\nAccept (hijau) / Decline (merah) + GuardianShowIncomingCall --> GuardianInCallSession : Accept + GuardianShowIncomingCall --> GuardianIdle : Decline + GuardianInCallSession : Agora RTC connected + GuardianInCallSession --> GuardianIdle : End call + } + + GuardianIncomingCall --> GuardianIdle : Selesai + + ' --- LOGOUT --- + GuardianIdle --> GuardianLoggingOut : Tap Logout + + GuardianLoggingOut : POST /auth/logout {refreshToken}\nSecureStorage.clearAll()\nWebSocket.disconnect() + GuardianLoggingOut --> [*] +} + +' ============================================================ +' GLOBAL LOGOUT / TOKEN REFRESH +' ============================================================ +UserShell --> Unauthenticated : Logout / Token Expired (401 → refresh gagal) +GuardianShell --> Unauthenticated : Logout / Token Expired (401 → refresh gagal) + +note as TokenRefreshNote + **Token Refresh (AuthInterceptor):** + Setiap request: inject "Authorization: Bearer {accessToken}" + Jika 401 → POST /auth/refresh {refreshToken} + Jika refresh OK → retry request dengan token baru + Jika refresh 401 → navigate ke /login + Access token: 1 jam | Refresh token: 30 hari +end note + +' ============================================================ +' GEOFENCE EXIT (Parallel event) +' ============================================================ +note as GeofenceNote + **Geofence Alert (Backend event):** + Setiap POST /user/location (saat WalkGuide aktif, 5 detik): + Backend: GeofenceService.checkGeofence() + → Haversine distance dari center ke current position + → Jika keluar radius: FCM ke Guardian "User has left geofence!" + → ActivityLog: GEOFENCE_EXIT + → Guardian: notifikasi di GuardianDashboard +end note + +@enduml diff --git a/ooad-docs/diagrams/usecase-diagram.puml b/ooad-docs/diagrams/usecase-diagram.puml new file mode 100644 index 0000000..0613ce1 --- /dev/null +++ b/ooad-docs/diagrams/usecase-diagram.puml @@ -0,0 +1,176 @@ +@startuml +left to right direction +skinparam packageStyle rectangle +skinparam rectangle { + BackgroundColor<> LightGray + BorderColor Black +} + +' ========================================== +' AKTOR UTAMA (Kiri) +' ========================================== +actor "Tunanetra\n(ROLE_USER)" as User +actor "Pendamping\n(ROLE_GUARDIAN)" as Guardian + +' ========================================== +' BATAS SISTEM (Tengah) +' ========================================== +rectangle "WalkGuide System" { + + ' --- AUTH --- + usecase "Hubungkan ke Server" as UC_Server + usecase "Daftar Akun" as UC_Register + usecase "Masuk Sistem (Login)" as UC_Login + usecase "Logout" as UC_Logout + usecase "Validasi & Refresh JWT" as UC_JWT + usecase "RBAC Role Routing" as UC_RBAC + + ' --- PAIRING --- + usecase "Kirim Undangan Pairing" as UC_Invite + usecase "Terima / Tolak Pairing" as UC_PairingResponse + usecase "Putus Pairing (Unpair)" as UC_Unpair + + ' --- WALKGUIDE CORE --- + usecase "Aktifkan Walk Guide Mode" as UC_Walk + usecase "Deteksi Obstacle\nReal-time (YOLO)" as UC_Detect + usecase "Analisa Arah & Jarak\nObstacle" as UC_Analyze + usecase "Umpan Balik TTS (Audio)" as UC_TTS + usecase "Umpan Balik Vibrasi\n(Haptic)" as UC_Vib + usecase "Log Obstacle ke Backend" as UC_ObsLog + + ' --- LOKASI --- + usecase "Kirim Lokasi GPS\nReal-time (WebSocket)" as UC_Location + usecase "Mode Navigasi\n(OpenStreetMap)" as UC_Navigation + usecase "\"Where Am I?\"\n(Reverse Geocode)" as UC_WhereAmI + + ' --- VOICE & SHORTCUT --- + usecase "Perintah Suara\n(Speech-to-Text)" as UC_Voice + usecase "Shortcut Tombol\nFisik Hardware" as UC_Hardware + + ' --- SOS & DARURAT --- + usecase "Kirim SOS (Darurat)" as UC_SOS + usecase "Panggil Guardian\n(VoIP Agora)" as UC_Call + usecase "Terima Panggilan Masuk" as UC_IncomingCall + + ' --- NOTIFIKASI --- + usecase "Terima Notifikasi FCM" as UC_FCMReceive + usecase "Kirim Notifikasi\nTeks / Voice Note" as UC_SendNotif + usecase "Baca Notifikasi via TTS" as UC_ReadNotif + usecase "Tandai Notifikasi\nSudah Dibaca" as UC_MarkRead + + ' --- LOG --- + usecase "Lihat Log Aktivitas" as UC_ViewLog + usecase "Lihat Log Obstacle" as UC_ViewObsLog + + ' --- PENGATURAN --- + usecase "Atur Preferensi TTS\n& Haptic" as UC_UserSettings + usecase "Kelola Perintah\nSuara Kustom" as UC_VoiceConfig + usecase "Kelola Shortcut\nTombol Fisik" as UC_ShortcutConfig + + ' --- GUARDIAN DASHBOARD --- + usecase "Monitor Lokasi User\nReal-time (Map)" as UC_MonitorMap + usecase "Kelola Konfigurasi AI\n(Threshold YOLO)" as UC_AIConfig + usecase "Kelola Geofence\n(Radius Aman)" as UC_Geofence + usecase "Kelola Akun\nUser Paired" as UC_ManageUser + usecase "Konfirmasi/Acknowledge\nSOS" as UC_AckSOS +} + +' ========================================== +' AKTOR SISTEM (Kanan) +' ========================================== +rectangle "YOLOv8n\n(On-Device AI)" as AI <> +rectangle "Spring Boot\nBackend" as Backend <> +rectangle "Firebase FCM" as FCM <> +rectangle "Agora RTC" as Agora <> + +' ========================================== +' RELASI USER (ROLE_USER) +' ========================================== +User "*" -- "*" UC_Server +User "*" -- "*" UC_Login +User "*" -- "*" UC_Register +User "*" -- "*" UC_Logout +User "*" -- "*" UC_PairingResponse +User "*" -- "*" UC_Unpair +User "*" -- "*" UC_Walk +User "*" -- "*" UC_Voice +User "*" -- "*" UC_Hardware +User "*" -- "*" UC_SOS +User "*" -- "*" UC_Call +User "*" -- "*" UC_IncomingCall +User "*" -- "*" UC_Navigation +User "*" -- "*" UC_WhereAmI +User "*" -- "*" UC_FCMReceive +User "*" -- "*" UC_ReadNotif +User "*" -- "*" UC_MarkRead +User "*" -- "*" UC_ViewLog +User "*" -- "*" UC_UserSettings +User "*" -- "*" UC_ShortcutConfig + +' ========================================== +' RELASI GUARDIAN (ROLE_GUARDIAN) +' ========================================== +Guardian "*" -- "*" UC_Login +Guardian "*" -- "*" UC_Register +Guardian "*" -- "*" UC_Logout +Guardian "*" -- "*" UC_Invite +Guardian "*" -- "*" UC_Unpair +Guardian "*" -- "*" UC_MonitorMap +Guardian "*" -- "*" UC_SendNotif +Guardian "*" -- "*" UC_AIConfig +Guardian "*" -- "*" UC_Geofence +Guardian "*" -- "*" UC_VoiceConfig +Guardian "*" -- "*" UC_ShortcutConfig +Guardian "*" -- "*" UC_ManageUser +Guardian "*" -- "*" UC_IncomingCall +Guardian "*" -- "*" UC_ViewLog +Guardian "*" -- "*" UC_ViewObsLog +Guardian "*" -- "*" UC_Call +Guardian "*" -- "*" UC_AckSOS + +' ========================================== +' INCLUDE & EXTEND +' ========================================== +UC_Login ..> UC_JWT : <> +UC_Register ..> UC_JWT : <> +UC_JWT ..> UC_RBAC : <> + +UC_Walk ..> UC_Detect : <> +UC_Detect ..> UC_Analyze : <> +UC_Analyze <.. UC_TTS : <> +UC_Analyze <.. UC_Vib : <> +UC_Detect ..> UC_ObsLog : <> +UC_Walk ..> UC_Location : <> + +UC_Walk <.. UC_Voice : <> +UC_SOS <.. UC_Voice : <> +UC_Call <.. UC_Voice : <> + +UC_Walk <.. UC_Hardware : <> +UC_SOS <.. UC_Hardware : <> +UC_Call <.. UC_Hardware : <> + +UC_SOS ..> UC_FCMReceive : <> +UC_SendNotif ..> UC_FCMReceive : <> +UC_FCMReceive <.. UC_TTS : <> +UC_Call <.. UC_IncomingCall : <> +UC_Geofence <.. UC_FCMReceive : <> + +' ========================================== +' RELASI KE SISTEM EKSTERNAL +' ========================================== +UC_Detect "*" ----- "*" AI +UC_JWT "*" ----- "*" Backend +UC_ObsLog "*" ----- "*" Backend +UC_Location "*" ----- "*" Backend +UC_SOS "*" ----- "*" Backend +UC_SendNotif "*" ----- "*" Backend +UC_ViewLog "*" ----- "*" Backend +UC_ViewObsLog "*" ----- "*" Backend +UC_AIConfig "*" ----- "*" Backend +UC_FCMReceive "*" ----- "*" FCM +UC_SendNotif "*" ----- "*" FCM +UC_Call "*" ----- "*" Agora +UC_IncomingCall "*" ----- "*" Agora + +@enduml \ No newline at end of file diff --git a/report/main.tex b/report/main.tex new file mode 100644 index 0000000..0e1e342 --- /dev/null +++ b/report/main.tex @@ -0,0 +1,1471 @@ +\documentclass[12pt,a4paper]{report} + +% ─── Packages ─────────────────────────────────────────────────────────────── +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage[a4paper, top=2.5cm, bottom=2.5cm, left=3cm, right=2.5cm]{geometry} +\usepackage{lmodern} +\usepackage{microtype} +\usepackage{setspace} +\usepackage{parskip} +\usepackage{titlesec} +\usepackage{titletoc} +\usepackage{fancyhdr} +\usepackage{graphicx} +\usepackage{float} +\usepackage{caption} +\usepackage{subcaption} +\usepackage{booktabs} +\usepackage{longtable} +\usepackage{array} +\usepackage{tabularx} +\usepackage{multirow} +\usepackage{xcolor} +\usepackage{listings} +\usepackage{mdframed} +\usepackage{enumitem} +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{hyperref} +\usepackage{tcolorbox} +\usepackage{pgfplots} +\usepackage{tikz} +\usepackage{pifont} +\usepackage{fontawesome5} +\usepackage{soul} +\pgfplotsset{compat=1.18} +\tcbuselibrary{skins, breakable} +\usetikzlibrary{shapes.geometric, arrows.meta, positioning, fit, backgrounds} + +% ─── Colors ──────────────────────────────────────────────────────────────── +\definecolor{primaryblue}{RGB}{25, 82, 148} +\definecolor{accentblue}{RGB}{41, 128, 185} +\definecolor{lightblue}{RGB}{214, 234, 248} +\definecolor{darkgray}{RGB}{44, 62, 80} +\definecolor{medgray}{RGB}{127, 140, 141} +\definecolor{lightgray}{RGB}{245, 246, 250} +\definecolor{successgreen}{RGB}{39, 174, 96} +\definecolor{warnyellow}{RGB}{230, 126, 34} +\definecolor{dangerred}{RGB}{192, 57, 43} +\definecolor{codebg}{RGB}{248, 249, 250} +\definecolor{codefg}{RGB}{36, 41, 47} + +% ─── Hyperref ─────────────────────────────────────────────────────────────── +\hypersetup{ + colorlinks=true, + linkcolor=primaryblue, + urlcolor=accentblue, + citecolor=accentblue, + pdfauthor={Kelompok 08 -- Evan, Japson, Bambang}, + pdftitle={WalkGuide -- Final Exam Report}, + pdfsubject={Integrated Mobile Application Project}, + bookmarksnumbered=true, +} + +% ─── Typography ───────────────────────────────────────────────────────────── +\onehalfspacing +\setlength{\parindent}{0pt} +\setlength{\parskip}{6pt} + +% ─── Section Formatting ───────────────────────────────────────────────────── +\titleformat{\chapter}[display] + {\normalfont\huge\bfseries\color{primaryblue}} + {\chaptertitlename\ \thechapter}{16pt}{\Huge} +\titlespacing*{\chapter}{0pt}{10pt}{20pt} + +\titleformat{\section} + {\normalfont\Large\bfseries\color{primaryblue}}{\thesection}{1em}{} +\titleformat{\subsection} + {\normalfont\large\bfseries\color{accentblue}}{\thesubsection}{1em}{} +\titleformat{\subsubsection} + {\normalfont\normalsize\bfseries\color{darkgray}}{\thesubsubsection}{1em}{} + +% ─── Header & Footer ──────────────────────────────────────────────────────── +\pagestyle{fancy} +\fancyhf{} +\fancyhead[L]{\small\color{medgray}WalkGuide -- Final Exam Report} +\fancyhead[R]{\small\color{medgray}Kelompok 08} +\fancyfoot[C]{\thepage} +\renewcommand{\headrulewidth}{0.4pt} +\renewcommand{\footrulewidth}{0pt} + +% ─── Code Listing ─────────────────────────────────────────────────────────── +\lstdefinestyle{javastyle}{ + language=Java, + backgroundcolor=\color{codebg}, + basicstyle=\ttfamily\footnotesize\color{codefg}, + keywordstyle=\bfseries\color{primaryblue}, + commentstyle=\itshape\color{medgray}, + stringstyle=\color{successgreen}, + numberstyle=\tiny\color{medgray}, + numbers=left, + numbersep=8pt, + frame=single, + framerule=0.5pt, + rulecolor=\color{medgray}, + breaklines=true, + captionpos=b, + tabsize=4, + showstringspaces=false, +} +\lstdefinestyle{dartstyle}{ + language=Java, + backgroundcolor=\color{codebg}, + basicstyle=\ttfamily\footnotesize\color{codefg}, + keywordstyle=\bfseries\color{accentblue}, + commentstyle=\itshape\color{medgray}, + stringstyle=\color{successgreen}, + numbers=left, + numbersep=8pt, + frame=single, + framerule=0.5pt, + rulecolor=\color{medgray}, + breaklines=true, + captionpos=b, + tabsize=2, + showstringspaces=false, +} +\lstset{style=javastyle} + +% ─── Custom Boxes ──────────────────────────────────────────────────────────── +\tcbset{ + infobox/.style={ + enhanced, breakable, + colback=lightblue!40, colframe=primaryblue, + fonttitle=\bfseries, coltitle=white, + attach boxed title to top left={yshift=-2mm, xshift=4mm}, + boxed title style={colback=primaryblue, rounded corners}, + arc=4pt, boxrule=0.8pt, + }, + warnbox/.style={ + enhanced, breakable, + colback=warnyellow!10, colframe=warnyellow, + fonttitle=\bfseries, coltitle=white, + attach boxed title to top left={yshift=-2mm, xshift=4mm}, + boxed title style={colback=warnyellow, rounded corners}, + arc=4pt, boxrule=0.8pt, + }, + codebox/.style={ + enhanced, breakable, + colback=codebg, colframe=medgray, + arc=2pt, boxrule=0.5pt, + fontupper=\ttfamily\small, + } +} + +% ─── Custom Commands ───────────────────────────────────────────────────────── +\newcommand{\done}{\textcolor{successgreen}{\ding{51}}} +\newcommand{\partial}{\textcolor{warnyellow}{\ding{115}}} +\newcommand{\missing}{\textcolor{dangerred}{\ding{55}}} +\newcommand{\code}[1]{\texttt{\small#1}} +\newcommand{\filepath}[1]{\texttt{\small\color{accentblue}#1}} +\newcommand{\apipath}[1]{\texttt{\small\color{primaryblue}#1}} + +% ─── Table Column Types ────────────────────────────────────────────────────── +\newcolumntype{L}[1]{>{\raggedright\arraybackslash}p{#1}} +\newcolumntype{C}[1]{>{\centering\arraybackslash}p{#1}} +\newcolumntype{R}[1]{>{\raggedleft\arraybackslash}p{#1}} + +% ════════════════════════════════════════════════════════════════════════════ +\begin{document} +% ════════════════════════════════════════════════════════════════════════════ + +% ─── Cover Page ───────────────────────────────────────────────────────────── +\begin{titlepage} +\pagecolor{primaryblue} +\color{white} +\centering +\vspace*{2cm} + +{\fontsize{14}{18}\selectfont\textbf{LAPORAN FINAL EXAM}}\\[0.4cm] +{\fontsize{12}{16}\selectfont Object-Oriented Analysis and Design}\\[0.3cm] +{\fontsize{11}{14}\selectfont Flutter $\times$ Spring Boot $\times$ Artificial Intelligence} + +\vspace{1.5cm} +\begin{tikzpicture} + \draw[white, line width=2pt] (0,0) circle (2.8cm); + \node[white, font=\fontsize{40}{44}\selectfont] at (0,0.3) {\faEye}; + \node[white, font=\fontsize{12}{14}\selectfont\bfseries] at (0,-1.2) {WalkGuide}; +\end{tikzpicture} + +\vspace{1cm} +{\fontsize{36}{42}\selectfont\bfseries WalkGuide}\\[0.3cm] +{\fontsize{16}{20}\selectfont AI-Powered Navigation for the Visually Impaired} + +\vspace{2cm} +\begin{tcolorbox}[colback=white!15, colframe=white!40, arc=8pt, width=12cm] + \centering\color{white} + \begin{tabular}{ll} + \textbf{Kelompok} & 08 \\[4pt] + \textbf{Anggota 1} & Evan \\ + \textbf{Anggota 2} & Japson \\ + \textbf{Anggota 3} & Bambang \\[4pt] + \textbf{Mata Kuliah} & Object-Oriented Analysis and Design \\ + \textbf{Tanggal} & 19 Mei 2026 \\ + \end{tabular} +\end{tcolorbox} + +\vfill +{\small\color{white!70} Universitas $\cdot$ Surabaya $\cdot$ 2026} +\end{titlepage} +\nopagecolor + +% ─── Front Matter ──────────────────────────────────────────────────────────── +\pagenumbering{roman} +\fancyhead[L]{\small\color{medgray}WalkGuide -- Final Exam Report} + +% Abstract +\chapter*{Abstract} +\addcontentsline{toc}{chapter}{Abstract} + +WalkGuide is an AI-powered mobile navigation system designed to assist visually impaired individuals in safely traversing their environment. The system comprises a Flutter mobile application and a Spring Boot RESTful backend, connected in real-time through WebSocket (STOMP) and Firebase Cloud Messaging (FCM). The mobile application runs YOLOv8n object detection entirely on-device using TensorFlow Lite, providing obstacle alerts through Text-to-Speech and haptic feedback without requiring an internet connection for core navigation. + +The backend, deployed on a university PostgreSQL server, exposes 26 versioned REST endpoints secured with JWT access and refresh tokens and role-based access control (RBAC) for two distinct user roles: \textit{User} (visually impaired individual) and \textit{Guardian} (caregiver or companion). Guardians can monitor the User's real-time location on an interactive map, configure AI detection parameters, send text or voice-note notifications, set up geofence boundaries, and initiate VoIP audio calls via Agora RTC. An SOS alert system allows Users to instantly notify their Guardian with a single voice command or hardware button press. + +The system implements seven Gang-of-Four (GoF) design patterns spanning all three categories --- Creational, Structural, and Behavioral --- across both the Flutter and Spring Boot codebases. The backend is tested with JUnit 5, Mockito, MockMvc, and Testcontainers, achieving above 70\% JaCoCo line coverage. Load testing was conducted using k6 with up to 30 virtual users. The Flutter application follows Clean Architecture principles with BLoC/Cubit state management and is benchmarked on a physical Android device in profile mode. + +\textbf{Keywords:} Flutter, Spring Boot, YOLOv8, TensorFlow Lite, Obstacle Detection, Text-to-Speech, Visually Impaired, WebSocket, JWT, Clean Architecture, BLoC, GoF Design Patterns. + +\tableofcontents +\listoftables +\listoffigures +\newpage + +% ─── Main Content ───────────────────────────────────────────────────────────── +\pagenumbering{arabic} +\setcounter{page}{1} + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{Introduction} +% ═══════════════════════════════════════════════════════════════════════════ + +\section{Problem Background} + +According to the World Health Organization (WHO), over 2.2 billion people worldwide live with a visual impairment, of which at least 1 billion have conditions that could have been prevented or are yet to be addressed. For individuals with complete or severe visual impairment, navigating public spaces poses significant risks: unexpected obstacles such as vehicles, pedestrians, street furniture, and road hazards create daily challenges that can result in injury. + +Traditional mobility aids such as the white cane and guide dogs provide partial assistance but offer no real-time digital awareness or remote monitoring capabilities. Existing mobile applications for the visually impaired tend to focus on text recognition or turn-by-turn navigation without addressing real-time obstacle detection or caregiver connectivity. + +WalkGuide addresses this gap by combining on-device computer vision (YOLOv8n via TensorFlow Lite), real-time audio feedback (Text-to-Speech), and a connected guardian ecosystem into a single, cohesive system --- all running on consumer Android hardware without requiring dedicated or expensive specialized equipment. + +\section{Objectives} + +\begin{enumerate} + \item Design and implement an Android mobile application in Flutter that performs real-time obstacle detection using on-device AI inference. + \item Build a Spring Boot backend exposing a secure, versioned REST API that manages authentication, pairing, location tracking, notifications, SOS events, and AI configuration. + \item Connect the two systems in real-time using WebSocket (STOMP) for live location updates and SOS alerts, supplemented by Firebase Cloud Messaging (FCM) for background push notifications. + \item Apply rigorous Object-Oriented Analysis and Design (OOAD) methodology --- producing use case, class, sequence, state, ERD, and component diagrams --- before commencing development. + \item Implement at least four GoF design patterns (one per category: Creational, Structural, Behavioral) and document each with a traceability audit. + \item Demonstrate functional testing (JUnit, widget tests, integration tests) and performance benchmarking (k6 load tests, Flutter profile-mode metrics). +\end{enumerate} + +\section{Target Users} + +\begin{description} + \item[\textbf{User (ROLE\_USER):}] A visually impaired individual who uses the WalkGuide app as their primary navigation aid. They interact with the app primarily through voice commands and audio feedback (Text-to-Speech), with hardware button shortcuts as a fallback. + \item[\textbf{Guardian (ROLE\_GUARDIAN):}] A sighted caregiver, family member, or companion who monitors the User remotely through the Guardian dashboard. They can send messages, configure AI settings, set geofences, and initiate calls. +\end{description} + +\section{Scope and Limitations} + +\textbf{In scope:} +\begin{itemize} + \item Android-only Flutter mobile application (iOS not targeted in this release). + \item On-device YOLOv8n inference using TensorFlow Lite; 80 COCO object labels. + \item RESTful Spring Boot backend deployed on the university server (\code{202.46.28.160}). + \item PostgreSQL database managed through Flyway migrations (V1--V16). + \item Real-time communication via STOMP WebSocket and FCM. + \item VoIP audio calls via Agora RTC. +\end{itemize} + +\textbf{Out of scope / known limitations:} +\begin{itemize} + \item iOS support: requires a separate Firebase configuration and Agora entitlements. + \item Firebase FCM backend: currently log-only until production Firebase Admin credentials are supplied. + \item Agora live call: implemented at API and service level; requires production Agora App ID and Certificate for live RTC. + \item Depth estimation via dual-camera: planned as a future enhancement. + \item AI benchmark of multiple model variants: documented in Chapter 12 (AI Benchmark Analysis). +\end{itemize} + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{OOAD --- Pre-Development Design Artifacts} +% ═══════════════════════════════════════════════════════════════════════════ + +All design artifacts in this chapter were produced before development commenced, using PlantUML 1.2024.x. Source \code{.puml} files are committed to \filepath{ooad-docs/diagrams/} in the project repository. + +\section{Use Case Diagram} + +Figure~\ref{fig:usecase} depicts the primary actors and use cases of the WalkGuide system. The two human actors are \textit{User} (visually impaired individual) and \textit{Guardian} (caregiver). Three external system actors --- Firebase FCM, Agora RTC, and OpenStreetMap/OSRM --- are included as secondary actors to represent the external services consumed by the system. + +\begin{figure}[H] +\centering +\begin{tikzpicture}[ + actor/.style={draw, circle, minimum size=1cm, font=\small}, + usecase/.style={draw, ellipse, minimum width=3cm, minimum height=0.8cm, font=\small, align=center}, + system/.style={draw, rectangle, dashed, rounded corners, inner sep=8pt}, + >=Latex +] +% Actors left +\node[actor] (user) at (-6, 2) {}; +\node[below=2pt of user, font=\scriptsize\bfseries] {User}; +\node[actor] (guardian) at (-6, -3) {}; +\node[below=2pt of guardian, font=\scriptsize\bfseries] {Guardian}; + +% Actors right +\node[actor] (fcm) at (7, 2) {}; +\node[below=2pt of fcm, font=\scriptsize] {Firebase FCM}; +\node[actor] (agora) at (7, 0) {}; +\node[below=2pt of agora, font=\scriptsize] {Agora RTC}; +\node[actor] (maps) at (7, -2) {}; +\node[below=2pt of maps, font=\scriptsize] {OpenStreetMap}; + +% System boundary +\begin{scope}[on background layer] +\node[system, fit={(0,4.5)(5,-5.5)}, fill=lightblue!30, label={[font=\small\bfseries, color=primaryblue]above:WalkGuide System}] (sys) {}; +\end{scope} + +% Use cases +\node[usecase, fill=white] (ucauth) at (2.5, 4) {Register / Login}; +\node[usecase, fill=white] (ucpair) at (2.5, 3) {Pair Guardian \& User}; +\node[usecase, fill=white] (ucwalk) at (2.5, 2) {Start WalkGuide}; +\node[usecase, fill=white] (ucdetect) at (2.5, 1) {Detect Obstacle}; +\node[usecase, fill=white] (ucloc) at (2.5, 0) {Report Location}; +\node[usecase, fill=white] (ucsos) at (2.5, -1) {Trigger SOS}; +\node[usecase, fill=white] (ucnotif) at (2.5, -2) {Read Notifications}; +\node[usecase, fill=white] (uccall) at (2.5, -3) {Call Partner}; +\node[usecase, fill=white] (ucdash) at (2.5, -4) {Monitor Dashboard}; +\node[usecase, fill=white] (ucconfig) at (2.5, -5) {Configure AI / Geofence}; + +% Arrows - User +\draw[->] (user) -- (ucauth); +\draw[->] (user) -- (ucpair); +\draw[->] (user) -- (ucwalk); +\draw[->] (user) -- (ucsos); +\draw[->] (user) -- (ucnotif); +\draw[->] (user) -- (uccall); + +% include +\draw[->, dashed] (ucwalk) -- node[right, font=\scriptsize]{$\langle\langle$include$\rangle\rangle$} (ucdetect); +\draw[->, dashed] (ucwalk) -- node[right, font=\scriptsize]{$\langle\langle$include$\rangle\rangle$} (ucloc); + +% Arrows - Guardian +\draw[->] (guardian) -- (ucauth); +\draw[->] (guardian) -- (ucpair); +\draw[->] (guardian) -- (uccall); +\draw[->] (guardian) -- (ucdash); +\draw[->] (guardian) -- (ucconfig); + +% Arrows - External +\draw[->] (ucsos) -- (fcm); +\draw[->] (ucnotif) -- (fcm); +\draw[->] (uccall) -- (agora); +\draw[->] (uccall) -- (fcm); +\draw[->] (ucloc) -- (maps); +\end{tikzpicture} +\caption{WalkGuide Use Case Diagram} +\label{fig:usecase} +\end{figure} + +\section{Class Diagram} + +The domain model consists of 13 core entity classes, 5 controller classes, and 14 service classes. Table~\ref{tab:entities} summarizes the primary entities and their key attributes. The full class diagram is rendered from \filepath{ooad-docs/diagrams/class-diagram.puml}. + +\begin{table}[H] +\centering +\caption{Core Domain Entity Classes} +\label{tab:entities} +\begin{tabularx}{\textwidth}{L{3.2cm} L{4.5cm} L{5.5cm}} +\toprule +\textbf{Entity} & \textbf{Key Attributes} & \textbf{Key Relationships} \\ +\midrule +\code{User} & id, email, role, uniqueUserId, displayName, fcmToken & One-to-many with LocationHistory, ActivityLog, SosEvent \\ +\code{PairingRelation} & id, status, invitedAt, respondedAt & Many-to-one with guardian (User) and user (User) \\ +\code{LocationHistory} & id, lat, lng, accuracy, speed, heading & Many-to-one with User \\ +\code{ActivityLog} & id, logType, description, metadata (JSONB) & Many-to-one with User \\ +\code{ObstacleLog} & id, label, confidence, direction, estimatedDist & Many-to-one with User \\ +\code{GuardianNotification} & id, notifType, content, voiceNoteUrl, isRead & Linked via PairingRelation \\ +\code{SosEvent} & id, triggerType, lat, lng, status & Many-to-one with User \\ +\code{UserSettings} & ttsLanguage, ttsPitch, ttsSpeed, hapticEnabled & One-to-one with User \\ +\code{AiConfig} & confidenceThreshold, alertDistanceClose, maxInferenceFps & Linked via PairingRelation \\ +\code{VoiceCommandConfig} & commandKey, triggerPhrase, enabled & Linked via PairingRelation \\ +\code{HardwareShortcut} & shortcutKey, buttonName, buttonCode, enabled & Linked via PairingRelation \\ +\code{GeofenceConfig} & centerLat, centerLng, radiusMeters, enabled & Linked via PairingRelation \\ +\code{RefreshToken} & token, expiresAt & Many-to-one with User \\ +\bottomrule +\end{tabularx} +\end{table} + +\section{Sequence Diagrams} + +\subsection{Sequence 1: Login Flow} + +\begin{figure}[H] +\centering +\begin{tikzpicture}[ + lifeline/.style={draw, rectangle, minimum width=2cm, minimum height=0.6cm, font=\scriptsize, fill=lightblue!40}, + msg/.style={->, font=\scriptsize}, + retmsg/.style={->, dashed, font=\scriptsize}, +] +\node[lifeline] (user) at (0,0) {User}; +\node[lifeline] (ui) at (2.5,0) {LoginScreen}; +\node[lifeline] (dio) at (5.5,0) {ApiClient}; +\node[lifeline] (ctrl) at (8.5,0) {AuthController}; +\node[lifeline] (svc) at (11,0) {AuthService}; +\node[lifeline] (db) at (13.5,0) {PostgreSQL}; + +\foreach \x in {0,2.5,5.5,8.5,11,13.5} + \draw[dashed, medgray] (\x,-0.3) -- (\x,-6.5); + +\draw[msg] (0,-0.7) -- node[above, font=\scriptsize]{enter credentials} (2.5,-0.7); +\draw[msg] (2.5,-1.2) -- node[above, font=\scriptsize]{POST /auth/login} (5.5,-1.2); +\draw[msg] (5.5,-1.7) -- node[above, font=\scriptsize]{LoginRequest} (8.5,-1.7); +\draw[msg] (8.5,-2.2) -- node[above, font=\scriptsize]{login(req)} (11,-2.2); +\draw[msg] (11,-2.7) -- node[above, font=\scriptsize]{findByEmail + tokens} (13.5,-2.7); +\draw[retmsg] (13.5,-3.2) -- node[above, font=\scriptsize]{user entity} (11,-3.2); +\draw[retmsg] (11,-3.7) -- node[above, font=\scriptsize]{AuthDataResponse} (8.5,-3.7); +\draw[retmsg] (8.5,-4.2) -- node[above, font=\scriptsize]{ApiResponse} (5.5,-4.2); +\draw[retmsg] (5.5,-4.7) -- node[above, font=\scriptsize]{tokens + role} (2.5,-4.7); +\draw[msg] (2.5,-5.2) -- node[above, font=\scriptsize]{save tokens, route by role} (0,-5.2); +\end{tikzpicture} +\caption{Sequence Diagram: Login Flow} +\label{fig:seq-login} +\end{figure} + +\subsection{Sequence 2: Guardian Invites User (Pairing)} + +The pairing flow is initiated by a Guardian who inputs the User's 12-character unique ID. Upon acceptance, the backend seeds 14 default voice command configurations, 5 hardware shortcut configurations, and one AI configuration record for the pair. + +\subsection{Sequence 3: SOS Alert End-to-End} + +\begin{figure}[H] +\centering +\begin{tikzpicture}[ + lifeline/.style={draw, rectangle, minimum width=1.8cm, minimum height=0.6cm, font=\scriptsize, fill=lightblue!40}, + msg/.style={->, font=\scriptsize}, + retmsg/.style={->, dashed, font=\scriptsize}, +] +\node[lifeline] (user) at (0,0) {User}; +\node[lifeline] (ui) at (2.3,0) {SosScreen}; +\node[lifeline] (ctrl) at (5,0) {UserController}; +\node[lifeline] (svc) at (7.7,0) {SosService}; +\node[lifeline] (db) at (10,0) {PostgreSQL}; +\node[lifeline] (fcm) at (12.3,0) {FCM}; +\node[lifeline] (dash) at (14.6,0) {GuardianDash}; + +\foreach \x in {0,2.3,5,7.7,10,12.3,14.6} + \draw[dashed, medgray] (\x,-0.3) -- (\x,-7.5); + +\draw[msg] (0,-0.7) -- node[above, font=\tiny]{press SOS} (2.3,-0.7); +\draw[msg] (2.3,-1.2) -- node[above, font=\tiny]{POST /user/sos} (5,-1.2); +\draw[msg] (5,-1.7) -- node[above, font=\tiny]{triggerSos()} (7.7,-1.7); +\draw[msg] (7.7,-2.2) -- node[above, font=\tiny]{save SosEvent} (10,-2.2); +\draw[retmsg] (10,-2.7) -- (7.7,-2.7); +\draw[msg] (7.7,-3.2) -- node[above, font=\tiny]{high-priority push} (12.3,-3.2); +\draw[msg] (7.7,-3.7) -- node[above, font=\tiny]{WS /queue/sos/\{guardianId\}} (14.6,-3.7); +\draw[retmsg] (7.7,-4.2) -- node[above, font=\tiny]{SosEventResponse} (5,-4.2); +\draw[retmsg] (5,-4.7) -- node[above, font=\tiny]{201 Created} (2.3,-4.7); +\draw[msg] (2.3,-5.2) -- node[above, font=\tiny]{TTS + haptic} (0,-5.2); +\draw[msg] (14.6,-5.7) -- node[above, font=\tiny]{PUT /sos/\{id\}/acknowledge} (5,-5.7); +\draw[msg] (5,-6.2) -- node[above, font=\tiny]{acknowledgeSos()} (7.7,-6.2); +\draw[msg] (7.7,-6.7) -- node[above, font=\tiny]{FCM to User: help coming} (12.3,-6.7); +\end{tikzpicture} +\caption{Sequence Diagram: SOS Alert Flow} +\label{fig:seq-sos} +\end{figure} + +\section{State Machine Diagram} + +The \code{SosEvent} entity transitions through three states: \texttt{TRIGGERED} (initial), \texttt{ACKNOWLEDGED} (Guardian confirms), and \texttt{RESOLVED} (incident closed). Figure~\ref{fig:state} illustrates these transitions. + +\begin{figure}[H] +\centering +\begin{tikzpicture}[ + state/.style={draw, rounded rectangle, minimum width=2.5cm, minimum height=0.8cm, font=\small, fill=lightblue!40}, + >=Latex +] +\node[fill=darkgray, circle, minimum size=0.4cm] (start) at (-2, 0) {}; +\node[state, fill=warnyellow!30] (triggered) at (1.5, 0) {TRIGGERED}; +\node[state, fill=accentblue!20] (acked) at (6, 0) {ACKNOWLEDGED}; +\node[state, fill=successgreen!20] (resolved) at (10, 0) {RESOLVED}; +\node[fill=darkgray, circle, minimum size=0.4cm, label=right:{}] (end) at (13, 0) {}; +\draw[thick, double distance=2pt] (end) circle (0.55cm); + +\draw[->] (start) -- (triggered); +\draw[->] (triggered) -- node[above, font=\scriptsize]{Guardian acknowledges} (acked); +\draw[->] (acked) -- node[above, font=\scriptsize]{incident handled} (resolved); +\draw[->, bend right=30] (triggered) to node[below, font=\scriptsize]{auto / manual close} (resolved); +\draw[->] (resolved) -- (end); +\end{tikzpicture} +\caption{State Machine Diagram: SosEvent} +\label{fig:state} +\end{figure} + +\section{Entity-Relationship Diagram} + +The database schema consists of 16 tables managed by Flyway migrations V1--V16. All tables use \code{BIGSERIAL} primary keys and \code{TIMESTAMP} audit fields. The central entity is \code{users}, which branches into both personal data (settings, tokens, logs) and relational data (pairing, notifications, configurations). + +\begin{table}[H] +\centering +\caption{Database Tables Summary (Flyway V1--V16)} +\label{tab:db} +\begin{tabularx}{\textwidth}{C{0.5cm} L{4cm} L{8.5cm}} +\toprule +\textbf{V\#} & \textbf{Table / Action} & \textbf{Description} \\ +\midrule +V1 & \code{users} & Core user table with email, password (BCrypt), role, display name, FCM token \\ +V2 & seed data & Seed default users for development testing \\ +V3 & guardian-user link & Initial guardian-user relationship scaffolding \\ +V4 & \code{ALTER users} & Add \code{unique\_user\_id} CHAR(12) column \\ +V5 & \code{pairing\_relations} & Guardian-User pairing with PENDING/ACTIVE/REJECTED status \\ +V6 & \code{activity\_logs} & All user activity events with JSONB metadata \\ +V7 & \code{obstacle\_logs} & YOLO detection records with direction and estimated distance \\ +V8 & \code{location\_history} & GPS coordinates with accuracy, speed, heading \\ +V9 & \code{guardian\_notifications} & Text and voice-note messages from Guardian to User \\ +V10 & \code{sos\_events} & SOS triggers with location and status transitions \\ +V11 & \code{user\_settings} & TTS language, pitch, speed, haptic preferences \\ +V12 & \code{ai\_configs} & YOLO confidence threshold, alert distances, max FPS \\ +V13 & \code{voice\_command\_configs} & 14 configurable voice trigger phrases per user pair \\ +V14 & \code{hardware\_shortcuts} & 5 hardware button assignments per user pair \\ +V15 & \code{geofence\_configs} & Geofence center coordinates and radius per user pair \\ +V16 & \code{refresh\_tokens} & JWT refresh tokens with 30-day expiry \\ +\bottomrule +\end{tabularx} +\end{table} + +\section{Component Diagram} + +The system is composed of four major runtime components: the Flutter mobile application, the Spring Boot backend, the PostgreSQL database, and three external cloud services (Firebase FCM, Agora RTC, OpenStreetMap/OSRM). Communication channels are shown in Figure~\ref{fig:component}. + +\begin{figure}[H] +\centering +\begin{tikzpicture}[ + comp/.style={draw, rectangle, rounded corners, minimum width=3cm, minimum height=1cm, font=\small, fill=lightblue!30}, + subcomp/.style={draw, rectangle, minimum width=2.4cm, minimum height=0.7cm, font=\scriptsize, fill=white}, + cloud/.style={draw, ellipse, minimum width=2.5cm, minimum height=1cm, font=\scriptsize, fill=warnyellow!20}, + db/.style={draw, cylinder, shape border rotate=90, minimum width=2cm, minimum height=1.2cm, font=\scriptsize, fill=successgreen!20}, + >=Latex +] +% Flutter +\node[comp, minimum width=4.5cm, minimum height=3.5cm, label=center:\textbf{}] (flutter) at (-4, 1) {}; +\node[font=\small\bfseries, color=primaryblue] at (-4, 2.5) {Flutter Mobile}; +\node[subcomp] at (-4, 1.7) {Screens + BLoC}; +\node[subcomp] at (-4, 0.9) {ApiClient (Dio)}; +\node[subcomp] at (-4, 0.1) {YOLO / TTS / STT}; +\node[subcomp] at (-4, -0.7) {WebSocketService}; + +% Spring Boot +\node[comp, minimum width=4.5cm, minimum height=3.5cm] (spring) at (4, 1) {}; +\node[font=\small\bfseries, color=primaryblue] at (4, 2.5) {Spring Boot}; +\node[subcomp] at (4, 1.7) {Controllers}; +\node[subcomp] at (4, 0.9) {Services}; +\node[subcomp] at (4, 0.1) {Repositories}; +\node[subcomp] at (4, -0.7) {WebSocket Broker}; + +% DB +\node[db] (db) at (4, -2.8) {PostgreSQL}; + +% Clouds +\node[cloud] (fcm) at (-4, -3) {Firebase FCM}; +\node[cloud] (agora) at (0, -3) {Agora RTC}; +\node[cloud] (maps) at (-4, -4.5) {OpenStreetMap}; + +% Arrows +\draw[<->, thick, color=primaryblue] (-2, 1) -- node[above, font=\scriptsize]{REST /api/v1} (2, 1); +\draw[<->, thick, color=accentblue] (-2, -0.3) -- node[below, font=\scriptsize]{STOMP /ws} (2, -0.3); +\draw[->] (spring) -- (db); +\draw[->] (spring) -- (fcm); +\draw[->] (flutter) -- (agora); +\draw[->] (flutter) -- (maps); +\end{tikzpicture} +\caption{WalkGuide System Component Diagram} +\label{fig:component} +\end{figure} + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{OOAD --- Design Pattern Documentation} +% ═══════════════════════════════════════════════════════════════════════════ + +Seven GoF design patterns are implemented across the WalkGuide codebase, covering all three categories. Table~\ref{tab:patterns} provides a summary; detailed documentation follows. + +\begin{table}[H] +\centering +\caption{GoF Design Patterns in WalkGuide} +\label{tab:patterns} +\begin{tabularx}{\textwidth}{L{2.5cm} L{2.2cm} L{4cm} L{5cm}} +\toprule +\textbf{Pattern} & \textbf{Category} & \textbf{Location} & \textbf{Justification} \\ +\midrule +Builder & Creational & \code{User.java}, \code{FcmService.java} & Many optional fields; fluent construction \\ +Singleton & Creational & \code{injection\_container.dart} & One shared lifecycle for resource-heavy services \\ +Facade & Structural & \code{TtsService}, \code{VoiceCommandHandler} & Hides plugin complexity from UI layer \\ +Repository & Structural & All \code{*Repository.java} interfaces & Decouples JPA from service logic \\ +Observer & Behavioral & BLoC/Cubit, \code{WebSocketService} & Reactive state; real-time push without polling \\ +Strategy & Behavioral & \code{obstacle\_analyzer.dart} & Swappable direction/distance thresholds \\ +Chain of Responsibility & Behavioral & Dio interceptors, Spring Security filter chain & Sequential request handling with pass-through \\ +\bottomrule +\end{tabularx} +\end{table} + +\section{Creational: Builder Pattern} + +\textbf{Location:} \filepath{walkguide-backend/demo/src/main/java/com/walkguide/entity/User.java}, \filepath{service/FcmService.java} + +\textbf{Implementation:} Lombok's \code{@Builder} annotation is applied to the \code{User} entity and all response DTO classes. The Firebase \code{Message} object in \code{FcmService} is constructed using the Agora SDK's native builder. + +\textbf{Justification:} The \code{User} entity has eight fields, five of which are optional (e.g., \code{displayName}, \code{uniqueUserId}, \code{fcmToken}). Using a builder prevents telescoping constructors and makes the construction intent explicit at every call site. + +\begin{lstlisting}[style=javastyle, caption={Builder Pattern -- User entity construction}] +// entity/User.java +@Entity @Builder @Data @NoArgsConstructor @AllArgsConstructor +public class User { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String email; + private String password; + private String role; + private String uniqueUserId; // optional: ROLE_USER only + private String displayName; + private String fcmToken; // optional: updated post-login + // ... +} + +// Usage in AuthService.java +User newUser = User.builder() + .email(req.getEmail()) + .password(encoder.encode(req.getPassword())) + .role(req.getRole()) + .displayName(req.getDisplayName()) + .build(); +\end{lstlisting} + +\section{Creational: Singleton Pattern} + +\textbf{Location:} \filepath{walkguide-mobile/walkguide\_app/lib/app/injection\_container.dart} + +\textbf{Implementation:} All resource-heavy services are registered as singletons in the GetIt service locator. This ensures that \code{TtsService}, \code{WebSocketService}, \code{YoloDetector}, and \code{CallService} each have exactly one instance throughout the application lifecycle. + +\textbf{Justification:} The YOLO interpreter holds the entire model in memory ($\sim$6 MB). Creating multiple instances would exhaust mobile heap. The WebSocket connection must be shared across screens to avoid duplicate STOMP subscriptions. + +\begin{lstlisting}[style=dartstyle, caption={Singleton Pattern -- GetIt registration}] +// injection_container.dart +void setupInjection(String baseUrl) { + getIt.registerLazySingleton(() => TtsService()); + getIt.registerLazySingleton(() => YoloDetector()); + getIt.registerLazySingleton(() => WebSocketService()); + getIt.registerLazySingleton(() => ApiClient(baseUrl)); + // Factories for BLoCs (new instance per screen): + getIt.registerFactory(() => AuthBloc(getIt())); +} +\end{lstlisting} + +\section{Structural: Facade Pattern} + +\textbf{Location:} \filepath{core/services/tts\_service.dart}, \filepath{core/services/voice\_command\_handler.dart} + +\textbf{Implementation:} \code{TtsService} wraps the \code{flutter\_tts} plugin behind a simple interface: \code{speak()}, \code{speakImmediate()}, \code{stop()}, and \code{setLanguage()}. Callers never interact with the raw plugin. Similarly, \code{VoiceCommandHandler} acts as a facade over STT recognition, voice command matching, BLoC event dispatching, and GoRouter navigation. + +\textbf{Justification:} Without the facade, WalkGuide screen widgets would need to manage plugin lifecycle, language configuration, and queue management directly --- coupling UI code to implementation details. + +\section{Structural: Repository Pattern} + +\textbf{Location:} All files in \filepath{walkguide-backend/demo/src/main/java/com/walkguide/repository/} + +\textbf{Implementation:} Each Spring Data JPA repository interface extends \code{JpaRepository} and defines domain-specific query methods. Services depend only on repository interfaces, never on concrete JPA or SQL APIs. + +\textbf{Justification:} Applying the Repository pattern enables full Mockito mocking of the persistence layer in unit tests. Swapping PostgreSQL for another database would require changing only the repository implementations, not the service layer. + +\section{Behavioral: Observer Pattern} + +\textbf{Location:} \filepath{lib/app/app\_cubit.dart}, all BLoC files, \filepath{core/services/websocket\_service.dart}, \filepath{walkguide-backend/.../websocket/LocationBroadcaster.java} + +\textbf{Implementation:} The BLoC/Cubit pattern is itself an implementation of Observer: the BLoC is the Subject, and all \code{BlocBuilder}/\code{BlocConsumer} widgets are Observers. On the backend, the Spring WebSocket broker pushes location, SOS, and notification updates to all subscribed Guardian clients without polling. + +\section{Behavioral: Strategy Pattern} + +\textbf{Location:} \filepath{core/ai/obstacle\_analyzer.dart} + +\textbf{Implementation:} \code{ObstacleAnalyzer} encapsulates two independent strategies: \code{analyzeDirection()} (which divides the camera frame into thirds) and \code{estimateDistance()} (which uses bounding-box height ratio against configurable thresholds from \code{AiConfig}). Changing the Guardian's AI configuration updates the threshold values without touching the analyzer algorithm. + +\section{Behavioral: Chain of Responsibility Pattern} + +\textbf{Location:} \filepath{core/network/api\_client.dart} (Dio interceptors), \filepath{security/JwtAuthFilter.java} + +\textbf{Implementation:} Every outgoing HTTP request passes through a chain: \textit{AuthInterceptor} (inject JWT Bearer token) $\to$ \textit{ErrorInterceptor} (normalize HTTP errors to typed \code{Failure} objects) $\to$ \textit{LogInterceptor} (development logging). On the backend, Spring Security's filter chain processes each request through \code{JwtAuthFilter} before reaching controllers. + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{OOAD --- Design Traceability Audit} +% ═══════════════════════════════════════════════════════════════════════════ + +\section{Use Case to Code Mapping} + +\begin{longtable}{L{3.5cm} L{3.5cm} L{4cm} C{2cm}} +\caption{Use Case Traceability Matrix} \label{tab:traceability} \\ +\toprule +\textbf{Use Case} & \textbf{Flutter Entry} & \textbf{Backend Entry} & \textbf{Status} \\ +\midrule +\endfirsthead +\multicolumn{4}{c}{\textit{(continued from previous page)}} \\ +\toprule +\textbf{Use Case} & \textbf{Flutter Entry} & \textbf{Backend Entry} & \textbf{Status} \\ +\midrule +\endhead +\bottomrule +\endfoot +Register / Login & \code{features/auth/*} & \code{AuthController}, \code{AuthService} & \done \\ +Pair Guardian \& User & \code{features/pairing/pairing\_screens.dart} & \code{PairingController}, \code{PairingService} & \done \\ +Start / Stop WalkGuide & \code{features/walk\_guide/walk\_guide\_screen.dart} & \code{POST /user/walkguide/start,stop} & \done \\ +Detect Obstacle & \code{core/ai/*}, \code{walk\_guide\_screen.dart} & \code{POST /user/obstacle} & \textcolor{warnyellow}{Partial}* \\ +Report Location & \code{LocationReporterService} & \code{LocationService}, \code{LocationBroadcaster} & \done \\ +Trigger SOS & \code{features/sos/sos\_screen.dart} & \code{SosService}, \code{GuardianController} & \done \\ +Read Notifications & \code{features/notifications/notification\_screen.dart} & \code{NotificationService}, \code{FcmService} & \done \\ +Monitor Dashboard & \code{guardian\_dashboard\_screen.dart} & \code{GuardianDashboardService} & \done \\ +Call Partner & \code{features/call/call\_screen.dart}, \code{CallService} & \code{CallController}, \code{AgoraTokenService} & \textcolor{warnyellow}{Partial}** \\ +Configure AI / Geofence & \code{guardian\_ai\_config\_screen.dart} & \code{AiConfigService}, \code{GeofenceService} & \done \\ +Navigate Route & \code{features/navigation\_mode/} & \code{POST /user/location} + OSRM & \done \\ +\end{longtable} + +\textit{* Obstacle detection code complete; requires \code{yolov8n.tflite} asset file for live inference.}\\ +\textit{** Call API and service complete; requires production Agora App ID and Certificate for live RTC.} + +\section{Design Deviations} + +\begin{tcolorbox}[warnbox, title={Documented Design Deviations}] +\begin{enumerate} + \item \textbf{State Management (Partial BLoC):} The architecture document specifies BLoC for all screens. In implementation, several guardian dashboard screens use \code{StatefulWidget} with direct service calls for simplicity during the exam sprint. This trades architectural purity for development speed; the deviation is localized to screens that do not require complex state transitions. + + \item \textbf{Offline Storage (Partial Drift):} The full architecture specifies Drift (SQLite ORM) for offline-first entity caching. The current implementation uses \code{SharedPreferences} for a lightweight offline queue (\code{offline\_queue\_service.dart}) instead of a full Drift database. The Drift dependency is present in \code{pubspec.yaml} for future extension. + + \item \textbf{Backend FCM (Log-Only):} The \code{FcmService} currently logs the push notification payload instead of calling \code{FirebaseMessaging.send()}. This is an intentional deferral pending the provisioning of a production Firebase Admin credential file (\code{google-services-admin.json}). + + \item \textbf{i18n (Dependency Only):} Full internationalization with \code{.arb} locale files was not completed within the exam timeline. The \code{flutter\_localizations} dependency is declared; UI strings remain in English. +\end{enumerate} +\end{tcolorbox} + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{System Architecture} +% ═══════════════════════════════════════════════════════════════════════════ + +\section{Flutter Clean Architecture} + +The Flutter application is organized in a feature-first directory structure. Each feature module contains three sub-layers following Clean Architecture: + +\begin{description} + \item[\textbf{Domain Layer}] Contains entities (pure Dart classes with no framework dependencies), abstract repository interfaces, and use cases. This layer has zero external dependencies. + \item[\textbf{Data Layer}] Contains concrete repository implementations, data source classes (remote API and local storage), and data models (JSON serialization). This layer depends on Dio, SharedPreferences, and platform services. + \item[\textbf{Presentation Layer}] Contains BLoC/Cubit classes, screen widgets, and local widget components. This layer depends only on the domain layer (via use cases) and never imports from the data layer directly. +\end{description} + +\begin{table}[H] +\centering +\caption{Flutter Project Structure Summary} +\label{tab:flutter-structure} +\begin{tabularx}{\textwidth}{L{5cm} L{8.5cm}} +\toprule +\textbf{Directory} & \textbf{Contents} \\ +\midrule +\code{lib/app/} & App entry, router (GoRouter), GetIt injection container \\ +\code{lib/core/ai/} & YoloDetector, ObstacleAnalyzer, model loader \\ +\code{lib/core/network/} & ApiClient (Dio), AuthInterceptor, ErrorInterceptor \\ +\code{lib/core/services/} & TtsService, SttService, VoiceCommandHandler, WebSocketService, FCMService, HapticService \\ +\code{lib/core/storage/} & SecureStorage (JWT tokens), OfflineQueueService \\ +\code{lib/features/auth/} & Login, Register, Splash screens + AuthBloc \\ +\code{lib/features/walk\_guide/} & WalkGuide screen with camera stream + YOLO pipeline \\ +\code{lib/features/sos/} & SOS screen + SosCubit \\ +\code{lib/features/notifications/} & Notification screen + NotificationBloc \\ +\code{lib/features/pairing/} & User/Guardian pairing screens + PairingBloc \\ +\code{lib/features/guardian\_dashboard/} & Dashboard, map, AI config, voice cmd, shortcut, geofence screens \\ +\code{lib/features/call/} & VoIP call screen + CallService (Agora) \\ +\code{lib/features/navigation\_mode/} & OpenStreetMap navigation screen \\ +\code{lib/shared/widgets/} & Reusable shells, navigation bars \\ +\bottomrule +\end{tabularx} +\end{table} + +\section{Spring Boot Layered Architecture} + +The backend follows a strict three-layer architecture: + +\begin{enumerate} + \item \textbf{Controller Layer:} Handles HTTP request/response, input validation (\code{@Valid}), and delegates to services. Contains no business logic. + \item \textbf{Service Layer:} Implements all business logic, orchestrates repository calls, publishes WebSocket events, and calls FCM/Agora services. + \item \textbf{Repository Layer:} Spring Data JPA interfaces. Contains only query method declarations; no SQL strings in the Java code. +\end{enumerate} + +\section{Real-Time Architecture} + +Two complementary mechanisms provide real-time connectivity: + +\begin{description} + \item[\textbf{WebSocket (STOMP):}] Used for low-latency, high-frequency updates when both parties are online and the app is in the foreground. Topics: \code{/topic/location/\{userId\}} (live location for Guardian), \code{/queue/sos/\{guardianId\}} (SOS alerts), \code{/queue/notif/\{userId\}} (incoming notifications). + \item[\textbf{Firebase Cloud Messaging (FCM):}] Used for background delivery when the app is not in the foreground, and for high-priority alerts (SOS, incoming calls). FCM payloads include a \code{type} field (\code{SOS\_ALERT}, \code{INCOMING\_CALL}, \code{NOTIFICATION}, etc.) that drives the Flutter notification handler. +\end{description} + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{API Contract} +% ═══════════════════════════════════════════════════════════════════════════ + +\section{Response Envelope} + +All endpoints return a consistent JSON envelope: +\begin{tcblisting}{codebox} +{ + "success": true, + "data": { ... }, + "message": "Operation successful", + "timestamp": "2026-05-19T10:30:00Z" +} +\end{tcblisting} + +Error responses use \code{success: false} with an \code{errorCode} field (\code{AUTH\_ERROR}, \code{NOT\_FOUND}, \code{PAIRING\_ERROR}, \code{VALIDATION\_ERROR}, \code{INTERNAL\_ERROR}). + +\section{Endpoint Summary} + +\begin{longtable}{L{1cm} L{5cm} L{4.5cm} C{2cm}} +\caption{REST API Endpoints (26 Total)} \label{tab:api} \\ +\toprule +\textbf{Method} & \textbf{Path} & \textbf{Description} & \textbf{Auth} \\ +\midrule +\endfirsthead +\multicolumn{4}{c}{\textit{(continued)}} \\ +\toprule +\textbf{Method} & \textbf{Path} & \textbf{Description} & \textbf{Auth} \\ +\midrule +\endhead +\bottomrule +\endfoot +\multicolumn{4}{l}{\textit{Auth (/api/v1/auth)}} \\ +GET & /auth/ping & Health check / server test & None \\ +POST & /auth/register & Register Guardian or User & None \\ +POST & /auth/login & Login, receive JWT tokens & None \\ +POST & /auth/refresh & Refresh access token & None \\ +POST & /auth/logout & Logout, revoke refresh token & JWT \\ +PUT & /auth/fcm-token & Update FCM device token & JWT \\ +\midrule +\multicolumn{4}{l}{\textit{Pairing (/api/v1/shared/pairing)}} \\ +POST & /pairing/invite & Guardian invites User by unique ID & GUARDIAN \\ +POST & /pairing/respond & User accepts / rejects invite & USER \\ +DELETE & /pairing/unpair & Dissolve active pairing & JWT \\ +GET & /pairing/status & Get current pairing status & JWT \\ +\midrule +\multicolumn{4}{l}{\textit{Guardian (/api/v1/guardian)}} \\ +GET & /guardian/dashboard & Combined dashboard data & GUARDIAN \\ +GET & /guardian/user-status & Paired user status & GUARDIAN \\ +GET & /guardian/user-location & Last known GPS location & GUARDIAN \\ +GET & /guardian/location-history & Location history (paginated) & GUARDIAN \\ +GET & /guardian/activity-logs & User activity logs & GUARDIAN \\ +POST & /guardian/notifications/send & Send text or voice note & GUARDIAN \\ +GET & /guardian/sos-events & All SOS events & GUARDIAN \\ +PUT & /guardian/sos/\{id\}/acknowledge & Acknowledge SOS & GUARDIAN \\ +PUT & /guardian/ai-config & Update AI configuration & GUARDIAN \\ +PUT & /guardian/voice-commands & Update voice command phrase & GUARDIAN \\ +PUT & /guardian/geofence & Update geofence config & GUARDIAN \\ +\midrule +\multicolumn{4}{l}{\textit{User (/api/v1/user)}} \\ +GET & /user/profile & User profile & USER \\ +GET & /user/settings & TTS and app settings & USER \\ +PUT & /user/settings & Update user settings & USER \\ +POST & /user/location & Send GPS update & USER \\ +POST & /user/obstacle & Log detected obstacle & USER \\ +POST & /user/sos & Trigger SOS alert & USER \\ +GET & /user/notifications & Notifications from Guardian & USER \\ +PUT & /user/notifications/mark-all-read & Mark all as read & USER \\ +POST & /user/walkguide/start & Log WalkGuide session start & USER \\ +POST & /user/walkguide/stop & Log WalkGuide session stop & USER \\ +\midrule +\multicolumn{4}{l}{\textit{Shared (/api/v1/shared)}} \\ +POST & /call/token & Generate Agora RTC token & JWT \\ +POST & /call/notify & Send FCM incoming call push & JWT \\ +\end{longtable} + +The full OpenAPI 3.0 specification is committed to \filepath{walkguide-backend/demo/src/main/resources/openapi.yaml} and rendered by Swagger UI at \code{/swagger-ui.html}. + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{Flutter Implementation} +% ═══════════════════════════════════════════════════════════════════════════ + +\section{Key Features} + +\subsection{WalkGuide --- On-Device AI Obstacle Detection} + +The core feature of WalkGuide is real-time obstacle detection using YOLOv8n (nano variant) running entirely on-device via TensorFlow Lite. The inference pipeline is: + +\begin{enumerate} + \item Camera stream delivers \code{CameraImage} frames in YUV420 format. + \item \code{YoloDetector.detect()} converts YUV to RGB, resizes to $640 \times 640$, normalizes to $[0.0, 1.0]$, and runs the TFLite interpreter. + \item Output tensor $[1, 84, 8400]$ is post-processed: 8400 anchor boxes $\times$ 84 values (4 coordinates + 80 COCO class probabilities). + \item Non-Maximum Suppression (NMS, IoU threshold 0.45) removes duplicate detections. + \item \code{ObstacleAnalyzer} computes direction (LEFT/CENTER/RIGHT from bounding box center-x) and distance estimate (Very Close / Close / Medium / Far from bounding box height ratio). + \item TTS announcement is generated: \textit{"Caution! Person ahead center. Very close. Please stop."} + \item Haptic pattern fires proportional to distance severity. + \item Obstacle is logged to the backend via \code{POST /user/obstacle} with a 3-second debounce per label. +\end{enumerate} + +\begin{table}[H] +\centering +\caption{YOLO Inference Configuration} +\label{tab:yolo} +\begin{tabular}{ll} +\toprule +\textbf{Parameter} & \textbf{Value} \\ +\midrule +Model & YOLOv8n (nano) \\ +Format & TensorFlow Lite Float32 \\ +Input shape & $[1, 640, 640, 3]$ \\ +Output shape & $[1, 84, 8400]$ \\ +CPU threads & 4 \\ +Delegate & NNAPI (Android AI accelerator) \\ +Default confidence threshold & 0.5 (configurable by Guardian) \\ +Default max inference FPS & 5 \\ +Model size & $\approx$ 6 MB \\ +Labels & 80 COCO classes \\ +\bottomrule +\end{tabular} +\end{table} + +\subsection{Voice Command System} + +The always-on STT listener (\code{SttService}) runs continuously while the app is in the foreground. Recognized text is passed to \code{VoiceCommandHandler}, which performs case-insensitive substring matching against the 14 configurable trigger phrases stored in the local voice command cache. Matched commands dispatch events to the appropriate BLoC or trigger GoRouter navigation. + +Default trigger phrases include: \textit{"Start WalkGuide"}, \textit{"Send SOS"}, \textit{"Call Guardian"}, \textit{"Where Am I"}, \textit{"Read All My Notifications"}, and 9 others --- all configurable by the Guardian remotely. + +\subsection{Guardian Dashboard} + +The Guardian application provides a real-time monitoring interface: +\begin{itemize} + \item \textbf{Live Map:} OpenStreetMap tiles (no API key required) with a moving marker updated via STOMP WebSocket subscription (\code{/topic/location/\{userId\}}). + \item \textbf{AI Configuration:} Sliders for confidence threshold, alert distances, and max FPS; multi-select for enabled object labels. + \item \textbf{Geofence:} Tap-to-set center on the map with a configurable radius (50m--5000m); exit triggers an FCM push notification. + \item \textbf{Notifications:} Send text messages or voice notes to the User; voice notes are recorded using the \code{record} package and played on the User side via \code{just\_audio}. +\end{itemize} + +\subsection{Advanced Features} + +\begin{table}[H] +\centering +\caption{Advanced Features Implementation Status} +\label{tab:advanced} +\begin{tabularx}{\textwidth}{L{5cm} C{2cm} L{6cm}} +\toprule +\textbf{Feature} & \textbf{Status} & \textbf{Implementation} \\ +\midrule +Real-time WebSocket & \done & STOMP via \code{stomp\_dart\_client}; live location + SOS + notifications \\ +Push Notifications (FCM) & \textcolor{warnyellow}{\ding{115}} & Flutter side complete; backend log-only pending credentials \\ +Offline-first with sync & \textcolor{warnyellow}{\ding{115}} & \code{OfflineQueueService} with \code{SharedPreferences} queue \\ +Animated transitions & \done & \code{flutter\_animate} + custom \code{AnimatedContainer} for detection overlay \\ +Internationalization & \textcolor{dangerred}{\ding{55}} & Dependency declared; \code{.arb} files not yet created \\ +\bottomrule +\end{tabularx} +\end{table} + +\section{State Management} + +The application uses flutter\_bloc (BLoC and Cubit) as the primary state management solution. Key BLoCs include \code{AuthBloc} (login/register/logout), \code{PairingBloc} (invite/respond/unpair), and \code{NotificationBloc} (load/read/TTS). Screen-level Cubits handle simpler local state (e.g., \code{SosCubit}, \code{AppCubit}). The global \code{AppCubit} tracks authentication state across route guards. + +\section{Navigation (GoRouter)} + +GoRouter is configured with redirect guards: unauthenticated users are redirected to \code{/server-connect} (if no server URL is saved) or \code{/login}; authenticated users are routed to \code{/user/home} or \code{/guardian/home} based on their role claim from the JWT. + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{Spring Boot Implementation} +% ═══════════════════════════════════════════════════════════════════════════ + +\section{Security Configuration} + +Spring Security is configured with stateless session management (no server-side sessions). JWT access tokens expire in 1 hour; refresh tokens expire in 30 days and are stored in the \code{refresh\_tokens} table. Role-based routing: + +\begin{itemize} + \item \code{/api/v1/auth/**} --- \code{permitAll()} (public) + \item \code{/api/v1/guardian/**} --- \code{hasRole("GUARDIAN")} + \item \code{/api/v1/user/**} --- \code{hasRole("USER")} + \item \code{/api/v1/shared/**} --- \code{authenticated()} (either role) + \item \code{/ws/**} --- \code{permitAll()} (WebSocket handshake uses token in STOMP CONNECT frame) +\end{itemize} + +\section{Database and Flyway} + +The application connects to a PostgreSQL instance at the university server (\code{202.46.28.160:2002}). Flyway manages all schema changes; Hibernate is set to \code{ddl-auto=validate} to ensure the live schema matches entity definitions. Sixteen migration scripts (V1--V16) create all tables in dependency order. + +\section{Key Service Implementations} + +\subsection{PairingService} + +When a Guardian accepts a pairing, \code{PairingService.respondToPairing()} performs an atomic sequence: +\begin{enumerate} + \item Update \code{PairingRelation} status to \code{ACTIVE}. + \item Seed 14 default \code{VoiceCommandConfig} records. + \item Seed 5 default \code{HardwareShortcut} records. + \item Create one \code{AiConfig} record with default thresholds. + \item Create one \code{GeofenceConfig} record (disabled by default). + \item Send FCM notification to the Guardian: "User accepted pairing request." +\end{enumerate} + +\subsection{LocationService} + +On each \code{POST /user/location} call, \code{LocationService}: +\begin{enumerate} + \item Saves a \code{LocationHistory} record. + \item Broadcasts the position via WebSocket to \code{/topic/location/\{userId\}}. + \item Checks \code{GeofenceConfig} if enabled, computing Haversine distance: +\end{enumerate} + +\begin{equation} +d = 2r \cdot \arcsin\!\left(\sqrt{\sin^2\!\!\left(\frac{\Delta\phi}{2}\right) + \cos\phi_1\cos\phi_2\sin^2\!\!\left(\frac{\Delta\lambda}{2}\right)}\right) +\end{equation} + +where $r = 6{,}371{,}000$ m (Earth radius), $\phi$ is latitude in radians, and $\lambda$ is longitude in radians. If $d > \text{radiusMeters}$, an FCM push is sent to the Guardian. + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{Flutter Testing \& Benchmarking} +% ═══════════════════════════════════════════════════════════════════════════ + +\section{Unit Tests} + +Unit tests are located in \filepath{walkguide-mobile/walkguide\_app/test/unit/}. All tests use the \code{flutter\_test} framework with Mockito for repository mocking. + +\begin{table}[H] +\centering +\caption{Flutter Unit Test Coverage} +\label{tab:flutter-unit} +\begin{tabularx}{\textwidth}{L{5cm} L{8cm}} +\toprule +\textbf{Test File} & \textbf{Scenarios Covered} \\ +\midrule +\code{login\_use\_case\_test.dart} & Successful login, wrong password, network failure \\ +\code{register\_use\_case\_test.dart} & ROLE\_USER (generates uniqueUserId), ROLE\_GUARDIAN, duplicate email \\ +\code{obstacle\_analyzer\_test.dart} & LEFT/CENTER/RIGHT direction from bounding box positions; Very Close/Close/Medium/Far distance ratios \\ +\code{voice\_command\_handler\_test.dart} & Case-insensitive matching: "start walkguide", "Start WalkGuide", "START WALK GUIDE"; no false positives \\ +\code{geofence\_calculation\_test.dart} & Haversine accuracy at known coordinates; boundary conditions at radius edge \\ +\bottomrule +\end{tabularx} +\end{table} + +\section{Widget Tests} + +Widget tests in \filepath{test/widget/} verify that core UI components render correctly and dispatch the expected BLoC events. + +\begin{table}[H] +\centering +\caption{Flutter Widget Test Coverage} +\label{tab:flutter-widget} +\begin{tabular}{ll} +\toprule +\textbf{Test File} & \textbf{Key Assertions} \\ +\midrule +\code{login\_screen\_test.dart} & Form renders, submit button triggers \code{LoginRequested} event \\ +\code{walk\_guide\_screen\_test.dart} & Camera preview widget renders, stop button visible \\ +\code{notification\_screen\_test.dart} & Notification list renders, unread badge visible \\ +\code{sos\_screen\_test.dart} & SOS button visible; tap dispatches \code{TriggerSos} event \\ +\code{manual\_screen\_test.dart} & All 14 default voice commands displayed \\ +\bottomrule +\end{tabular} +\end{table} + +\section{Integration Tests} + +Integration tests in \filepath{test/integration\_test/} exercise full end-to-end flows against the live Spring Boot backend. + +\begin{table}[H] +\centering +\caption{Flutter Integration Test Flows} +\label{tab:flutter-integration} +\begin{tabularx}{\textwidth}{L{5cm} L{8cm}} +\toprule +\textbf{Test File} & \textbf{Flow Covered} \\ +\midrule +\code{flow\_1\_login\_dashboard\_logout.dart} & Login with User credentials $\to$ dashboard loads $\to$ logout clears tokens \\ +\code{flow\_2\_walkguide\_start\_stop\_sos.dart} & Start WalkGuide $\to$ receive detection state $\to$ stop WalkGuide $\to$ trigger SOS \\ +\code{flow\_3\_notification\_read\_all.dart} & Guardian sends notification $\to$ User receives $\to$ mark all read $\to$ unread count = 0 \\ +\bottomrule +\end{tabularx} +\end{table} + +\section{Performance Benchmark} + +\begin{tcolorbox}[warnbox, title={Note on Benchmark Evidence}] +Physical-device benchmarks (DevTools profile mode) must be executed on the target Android device after the \code{yolov8n.tflite} model is added to the assets. The table below documents the benchmark plan and target thresholds per exam requirements. Results are to be filled in after on-device runs and updated in the submitted report. +\end{tcolorbox} + +\begin{table}[H] +\centering +\caption{Flutter Performance Benchmark Plan} +\label{tab:flutter-bench} +\begin{tabularx}{\textwidth}{L{3.5cm} L{3cm} R{2.5cm} C{2.5cm}} +\toprule +\textbf{Metric} & \textbf{Tool} & \textbf{Threshold} & \textbf{Result} \\ +\midrule +Memory baseline & DevTools Memory & Report MB & TBD \\ +Memory leak check & DevTools Memory & No steady growth & TBD \\ +Frame rate / jank & DevTools Performance & $\geq$90\% frames $<$ 16 ms & TBD \\ +CPU profile (YOLO inference) & DevTools CPU Profiler & Top 3 ops documented & TBD \\ +API latency (client-side) & Dio interceptor logs & $<$ 1500 ms & TBD \\ +Cold start time & \code{--trace-startup} & $<$ 3000 ms & TBD \\ +APK size & \code{--analyze-size} & $<$ 50 MB & TBD \\ +\bottomrule +\end{tabularx} +\end{table} + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{Backend Testing \& Benchmarking} +% ═══════════════════════════════════════════════════════════════════════════ + +\section{Unit Tests (JUnit 5 + Mockito)} + +Backend unit tests are in \filepath{src/test/java/com/walkguide/service/}. Each test class mocks all repository dependencies with Mockito. + +\begin{table}[H] +\centering +\caption{Backend Unit Test Classes} +\label{tab:backend-unit} +\begin{tabularx}{\textwidth}{L{5cm} L{8cm}} +\toprule +\textbf{Test Class} & \textbf{Scenarios Covered} \\ +\midrule +\code{AuthServiceTest} & Register (duplicate email), login (wrong password), refresh token (expired), logout \\ +\code{PairingServiceTest} & Invite (already paired), accept (seed defaults), reject, unpair (cascade delete) \\ +\code{NotificationServiceTest} & Send notification (unpaired guardian), mark read, unread count \\ +\code{SosServiceTest} & Trigger SOS, acknowledge, invalid status transition \\ +\code{LocationServiceTest} & Geofence Haversine distance accuracy, exit detection \\ +\code{AiConfigServiceTest} & Update validation (confidence 0--1), negative distance rejected \\ +\bottomrule +\end{tabularx} +\end{table} + +\section{Integration Tests (MockMvc + Testcontainers)} + +Controller integration tests use \code{@SpringBootTest} with MockMvc and Testcontainers to spin up a real PostgreSQL instance. Flyway migrations run automatically, providing a clean database for each test class. + +\begin{table}[H] +\centering +\caption{Backend Integration Test Classes} +\label{tab:backend-integration} +\begin{tabular}{ll} +\toprule +\textbf{Test Class} & \textbf{Key Assertions} \\ +\midrule +\code{AuthControllerTest} & POST /register returns 201; POST /login returns 200 with tokens \\ +\code{GuardianControllerTest} & GET /guardian/dashboard returns 403 if role is USER \\ +\code{UserControllerTest} & POST /user/sos returns 201; GET /user/notifications returns paginated list \\ +\bottomrule +\end{tabular} +\end{table} + +\section{JaCoCo Code Coverage} + +JaCoCo is configured with a minimum threshold of 70\% line coverage on the \code{service} and \code{controller} packages. The HTML coverage report is committed to \filepath{walkguide-backend/demo/target/site/jacoco/index.html} and generated via \code{mvn verify}. + +\section{Load Benchmarking (k6)} + +Load testing was performed using k6 with test scripts in \filepath{walkguide-backend/demo/k6-tests/}. Three test profiles were executed: smoke (1--3 VUs), load (30 VUs), and stress. + +\begin{table}[H] +\centering +\caption{k6 Load Test Results --- Auth Flow (30 VUs, Remote PostgreSQL)} +\label{tab:k6-auth} +\begin{tabular}{lrr} +\toprule +\textbf{Metric} & \textbf{Target} & \textbf{Observed} \\ +\midrule +Total requests & --- & 1{,}431 \\ +Request rate & $\geq$ 100 req/s & 7.45 req/s \\ +Avg. latency (all endpoints) & $<$ 500 ms & 1{,}553 ms \\ +p95 latency (auth endpoints) & $<$ 800 ms & 4{,}335 ms \\ +p95 latency (all endpoints) & $<$ 500 ms & 4{,}335 ms \\ +Error rate & $<$ 1\% & 0\% \\ +Successful requests & --- & 125 / 1{,}431 \\ +\bottomrule +\end{tabular} +\end{table} + +\begin{tcolorbox}[warnbox, title={Latency Explanation --- Remote PostgreSQL Bottleneck}] +The observed latency figures significantly exceed exam thresholds because the Spring Boot instance was running locally (localhost) while the PostgreSQL database is hosted on the university server at \code{202.46.28.160:2002}. Every database query incurs network round-trip latency of 500--3000 ms across the campus network. Under a co-located deployment (Spring Boot and PostgreSQL on the same server), latency would drop to the 50--200 ms range. This is a deployment infrastructure constraint, not a code performance issue. The error rate of 0\% demonstrates correct API behavior under load. +\end{tcolorbox} + +\begin{table}[H] +\centering +\caption{k6 Load Test Results --- Location Flow (Local Smoke, 3 VUs)} +\label{tab:k6-location} +\begin{tabular}{lrr} +\toprule +\textbf{Metric} & \textbf{Target} & \textbf{Observed} \\ +\midrule +Request rate & $\geq$ 100 req/s & 0.58 req/s \\ +Avg. latency & $<$ 500 ms & 4{,}456 ms \\ +p95 latency & $<$ 500 ms & 21{,}586 ms \\ +Error rate & $<$ 10\% & 0\% \\ +Max VUs & 30 & 3 \\ +\bottomrule +\end{tabular} +\end{table} + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{AI Model Benchmark Analysis} +% ═══════════════════════════════════════════════════════════════════════════ + +Per the lecturer's specific requirement, this chapter documents the AI inference benchmark plan for evaluating multiple YOLO model variants of varying sizes. The benchmark measures end-to-end latency from camera frame capture to TTS audio output. + +\section{Benchmark Methodology} + +The pipeline latency is decomposed into four measurable segments: + +\begin{enumerate} + \item $t_{\text{capture}}$: Time to acquire and pre-process one camera frame (YUV420 $\to$ RGB $\to$ resize $640 \times 640$ $\to$ normalize). + \item $t_{\text{infer}}$: TFLite interpreter \code{run()} call duration. + \item $t_{\text{post}}$: NMS post-processing and \code{ObstacleAnalyzer} computation. + \item $t_{\text{tts}}$: TTS \code{speak()} call to first audio sample playback. +\end{enumerate} + +\textbf{Total end-to-end latency:} $t_{\text{total}} = t_{\text{capture}} + t_{\text{infer}} + t_{\text{post}} + t_{\text{tts}}$ + +\section{Model Variants to Benchmark} + +\begin{table}[H] +\centering +\caption{YOLO Model Variants for Benchmarking} +\label{tab:models} +\begin{tabular}{lrrrr} +\toprule +\textbf{Model} & \textbf{Params} & \textbf{Size (MB)} & \textbf{mAP50} & \textbf{Target $t_{\text{infer}}$ (ms)} \\ +\midrule +YOLOv8n (nano) & 3.2 M & $\approx$ 6 & 37.3 & $<$ 100 \\ +YOLOv8s (small) & 11.2 M & $\approx$ 22 & 44.9 & $<$ 200 \\ +YOLOv5n (nano) & 1.9 M & $\approx$ 4 & 28.0 & $<$ 80 \\ +YOLOv5s (small) & 7.2 M & $\approx$ 14 & 37.4 & $<$ 150 \\ +MobileNetV3-SSD & 2.9 M & $\approx$ 8 & 22.0 & $<$ 60 \\ +\bottomrule +\end{tabular} +\end{table} + +\section{Benchmark Log Format} + +All benchmark results are logged to a structured in-app log accessible from a hidden developer screen (\code{/dev/ai-benchmark}). Each log entry contains: + +\begin{tcblisting}{codebox} +{ + "timestamp": "2026-05-19T10:30:00.123Z", + "model": "yolov8n", + "frame_id": 1024, + "t_capture_ms": 12.4, + "t_infer_ms": 87.3, + "t_post_ms": 3.1, + "t_tts_ms": 42.7, + "t_total_ms": 145.5, + "detections": 2, + "top_label": "person", + "confidence": 0.82, + "device": "Xiaomi Redmi Note 12", + "cpu_cores": 8, + "ram_gb": 8 +} +\end{tcblisting} + +\section{Expected Results (To Be Filled After Physical Device Testing)} + +\begin{table}[H] +\centering +\caption{AI Benchmark Results Template (Physical Device)} +\label{tab:ai-bench-results} +\begin{tabular}{lrrrrrr} +\toprule +\textbf{Model} & $t_{\text{cap}}$ & $t_{\text{infer}}$ & $t_{\text{post}}$ & $t_{\text{tts}}$ & $t_{\text{total}}$ & \textbf{FPS} \\ +\midrule +YOLOv8n & TBD & TBD & TBD & TBD & TBD & TBD \\ +YOLOv8s & TBD & TBD & TBD & TBD & TBD & TBD \\ +YOLOv5n & TBD & TBD & TBD & TBD & TBD & TBD \\ +YOLOv5s & TBD & TBD & TBD & TBD & TBD & TBD \\ +MobileNetV3 & TBD & TBD & TBD & TBD & TBD & TBD \\ +\bottomrule +\end{tabular} +\end{table} + +\textit{Note: All columns are in milliseconds. FPS = $1000 / t_{\text{total}}$.} + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{Location History Timeline} +% ═══════════════════════════════════════════════════════════════════════════ + +Per the lecturer's requirement, WalkGuide implements a Google Maps Timeline-style location history view. This feature is accessible to the Guardian from the dashboard and groups location data by time segment, transport mode, and distance. + +\section{Feature Design} + +The timeline screen groups \code{LocationHistory} records into segments using the following heuristics: + +\begin{itemize} + \item \textbf{Time gap $>$ 5 minutes:} New segment begins. + \item \textbf{Speed $<$ 1.5 m/s (5.4 km/h):} Classified as \textit{Walking}. + \item \textbf{Speed 1.5--5 m/s (5.4--18 km/h):} Classified as \textit{Cycling / Slow vehicle}. + \item \textbf{Speed $>$ 5 m/s (18 km/h):} Classified as \textit{Motorized vehicle (motorcycle/car)}. + \item \textbf{Speed = 0 for $>$ 3 minutes:} Classified as \textit{Stationary}. +\end{itemize} + +Each segment displays: +\begin{enumerate} + \item Start time and end time (e.g., \textit{08:15 -- 08:42}). + \item Place name (reverse-geocoded from OSM Nominatim API). + \item Transport mode icon (walk, bike, motorcycle, car, stationary). + \item Total distance covered (sum of Haversine distances between consecutive points). + \item A mini polyline map showing the route. +\end{enumerate} + +\section{API Support} + +The existing \code{GET /guardian/location-history} endpoint (paginated, ordered by \code{created\_at DESC}) provides the raw data. The timeline grouping logic runs client-side in the Guardian Flutter app. + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{Team Contribution} +% ═══════════════════════════════════════════════════════════════════════════ + +\begin{table}[H] +\centering +\caption{Team Member Responsibilities} +\label{tab:team} +\begin{tabularx}{\textwidth}{L{2.5cm} L{4cm} L{7cm}} +\toprule +\textbf{Member} & \textbf{Primary Role} & \textbf{Contributions} \\ +\midrule +Evan & Backend Engineer & Spring Boot architecture, all 14 service classes, Flyway migrations V4--V16, JWT security, WebSocket broadcaster, k6 load tests, JaCoCo coverage, OpenAPI YAML \\ +\midrule +Japson & Flutter Engineer & Flutter Clean Architecture setup, GoRouter configuration, WalkGuide YOLO pipeline, TTS/STT services, voice command handler, Guardian dashboard screens, Agora call integration \\ +\midrule +Bambang & OOAD Lead & All 6 OOAD diagram types (PlantUML), design pattern documentation, traceability audit, report authoring, Flutter unit/widget tests, integration test flows \\ +\bottomrule +\end{tabularx} +\end{table} + +All three members contributed commits to both the Flutter and Spring Boot repositories. OOAD artifacts were submitted before development commenced (Week 2--3 checkpoint). Contribution percentages are cross-referenced with GitHub commit history in the repository README. + +% ═══════════════════════════════════════════════════════════════════════════ +\chapter{Conclusion} +% ═══════════════════════════════════════════════════════════════════════════ + +\section{Achievements} + +WalkGuide successfully demonstrates a complete, integrated mobile application system that addresses a genuine social need: safe, independent navigation for visually impaired individuals. The following key achievements were realized: + +\begin{itemize} + \item A 26-endpoint Spring Boot REST API with JWT RBAC, Flyway migrations, WebSocket real-time communication, and comprehensive testing infrastructure. + \item A Flutter mobile application with on-device YOLOv8n obstacle detection, always-on voice command recognition, TTS feedback, guardian monitoring, SOS alerting, and VoIP calling --- all behind a dynamic server URL configuration that makes the APK deployable to any backend without recompilation. + \item Seven GoF design patterns implemented and documented across both codebases with full traceability to source files. + \item A guardian ecosystem that gives caregivers remote visibility and configurability: live map tracking, AI threshold tuning, geofencing, voice note delivery, and SOS acknowledgment. +\end{itemize} + +\section{Design Lessons Learned} + +\begin{enumerate} + \item \textbf{Observer (BLoC) scales better than StatefulWidget} for features with multiple consumers of the same state (e.g., notification badge count, pairing status). The screens that used StatefulWidget required more manual synchronization. + \item \textbf{Facade pattern pays dividends early:} Wrapping TTS, STT, and haptic APIs behind service facades from day one meant that screen code never needed to be updated when plugin APIs changed. + \item \textbf{Flyway migration order matters:} Foreign key constraints require strict creation order; planning V1--V16 upfront prevented migration failures caused by missing parent tables. + \item \textbf{Remote database latency is a first-class architectural concern:} The k6 results revealed that network latency between a local Spring Boot instance and the remote university PostgreSQL server is the dominant performance bottleneck. Co-locating the backend with the database would eliminate this. +\end{enumerate} + +\section{Challenges} + +\begin{itemize} + \item \textbf{TFLite model integration:} YUV420 $\to$ RGB conversion is not provided by any Flutter package for the \code{CameraImage} format; a custom pixel conversion loop was required. + \item \textbf{STT + TTS interaction:} The microphone and speaker compete for audio focus on Android. TTS had to be implemented with an interrupt-and-resume mechanism to prevent STT from hearing its own audio output. + \item \textbf{WebSocket authentication:} Spring Security does not natively protect STOMP WebSocket endpoints with JWT in the same way as REST. A custom handshake interceptor was required. +\end{itemize} + +\section{Future Improvements} + +\begin{itemize} + \item \textbf{Dual-camera depth estimation:} Using stereo camera data to compute actual metric distances to obstacles, replacing the bounding-box height heuristic. + \item \textbf{Full i18n:} Complete Bahasa Indonesia and English localization with \code{.arb} files. + \item \textbf{iOS support:} Requires a separate Firebase configuration and platform-specific audio session management. + \item \textbf{Custom YOLO model fine-tuning:} Training YOLOv8n on Indonesian urban obstacle datasets (ojek motorcycles, roadside vendors, irregular footpaths) to improve detection accuracy in local environments. + \item \textbf{Bluetooth accessibility device integration:} Support for dedicated tactile feedback wearables and screen reader integration. +\end{itemize} + +% ─── References ──────────────────────────────────────────────────────────── +\chapter*{References} +\addcontentsline{toc}{chapter}{References} + +\begin{enumerate}[label={[\arabic*]}] + \item World Health Organization. (2023). \textit{Blindness and Vision Impairment}. WHO Fact Sheet. Retrieved from https://www.who.int/news-room/fact-sheets/detail/blindness-and-visual-impairment + + \item Jocher, G., Chaurasia, A., \& Qiu, J. (2023). \textit{Ultralytics YOLO} (Version 8.0). Ultralytics. https://github.com/ultralytics/ultralytics + + \item TensorFlow Authors. (2024). \textit{TensorFlow Lite for Mobile \& Edge Devices}. Google. https://www.tensorflow.org/lite + + \item The Flutter Authors. (2024). \textit{Flutter SDK Documentation} (Version 3.x). Google. https://docs.flutter.dev + + \item Spring Boot Authors. (2024). \textit{Spring Boot Reference Documentation} (Version 3.3.x). VMware. https://docs.spring.io/spring-boot/docs/current/reference/html/ + + \item Gamma, E., Helm, R., Johnson, R., \& Vlissides, J. (1994). \textit{Design Patterns: Elements of Reusable Object-Oriented Software}. Addison-Wesley Professional. + + \item Martin, R. C. (2017). \textit{Clean Architecture: A Craftsman's Guide to Software Structure and Design}. Prentice Hall. + + \item Fowler, M. (2002). \textit{Patterns of Enterprise Application Architecture}. Addison-Wesley Professional. + + \item Agora.io. (2024). \textit{Agora RTC SDK for Flutter Documentation}. https://docs.agora.io/en/ + + \item Firebase Authors. (2024). \textit{Firebase Cloud Messaging Documentation}. Google. https://firebase.google.com/docs/cloud-messaging + + \item OpenStreetMap Contributors. (2024). \textit{OpenStreetMap}. https://www.openstreetmap.org + + \item k6. (2024). \textit{k6 --- Load Testing for Engineering Teams}. Grafana Labs. https://k6.io/docs/ +\end{enumerate} + +% ─── Appendix ────────────────────────────────────────────────────────────── +\appendix +\chapter{AI Tool Usage Disclosure} + +In accordance with the academic integrity requirements of this examination, we disclose the following use of AI coding assistants: + +\begin{itemize} + \item \textbf{Claude (Anthropic):} Used to assist with LaTeX report drafting (this document), review of architecture documentation, and generation of PlantUML diagram skeletons. All AI-generated content was reviewed, verified against the actual codebase, and corrected where necessary. + \item \textbf{GitHub Copilot:} Used during development for boilerplate code completion (DTO classes, repository method signatures). All suggestions were reviewed and integrated manually. +\end{itemize} + +No AI tool was used to produce OOAD diagrams without human review. No AI tool was used to fabricate benchmark results. + +\chapter{Deployment Configuration} + +\begin{table}[H] +\centering +\caption{Environment Configuration Reference} +\label{tab:env} +\begin{tabularx}{\textwidth}{L{4cm} L{8.5cm}} +\toprule +\textbf{Variable} & \textbf{Description} \\ +\midrule +\code{SPRING\_DATASOURCE\_URL} & \code{jdbc:postgresql://202.46.28.160:2002/uas\_5803024001} \\ +\code{SPRING\_DATASOURCE\_USERNAME} & Provided by university lecturer \\ +\code{SPRING\_DATASOURCE\_PASSWORD} & Provided by university lecturer (gitignored) \\ +\code{JWT\_SECRET} & Random 256-bit base64 string (gitignored) \\ +\code{AGORA\_APP\_ID} & Agora project App ID \\ +\code{AGORA\_APP\_CERTIFICATE} & Agora project certificate (gitignored) \\ +\code{FIREBASE\_ADMIN\_JSON} & Path to \code{google-services-admin.json} (gitignored) \\ +\bottomrule +\end{tabularx} +\end{table} + +\chapter{Voice Command Reference} + +\begin{table}[H] +\centering +\caption{Default Voice Commands (14 Commands)} +\label{tab:voicecmds} +\begin{tabular}{ll} +\toprule +\textbf{Command Key} & \textbf{Default Trigger Phrase} \\ +\midrule +\code{OPEN\_WALKGUIDE} & "Open Walkguide" \\ +\code{START\_WALKGUIDE} & "Start Walkguide" \\ +\code{STOP\_WALKGUIDE} & "Stop Walkguide" \\ +\code{CALL\_GUARDIAN} & "Call Guardian" \\ +\code{OPEN\_NOTIFICATION} & "Open Notifications" \\ +\code{READ\_ALL\_NOTIF} & "Read All My Notifications" \\ +\code{OPEN\_SOS} & "Open SOS" \\ +\code{SEND\_SOS} & "Send SOS" \\ +\code{WHERE\_AM\_I} & "Where Am I" \\ +\code{OPEN\_ACTIVITY} & "Open Activity Log" \\ +\code{OPEN\_NAVIGATION} & "Open Navigation" \\ +\code{OPEN\_SETTINGS} & "Open Settings" \\ +\code{REPEAT\_LAST} & "Repeat" \\ +\code{STOP\_TTS} & "Stop" \\ +\bottomrule +\end{tabular} +\end{table} + +All trigger phrases are configurable remotely by the Guardian via the Guardian Voice Command Configuration screen. + +\end{document} \ No newline at end of file diff --git a/walkguide-backend/demo/pom.xml b/walkguide-backend/demo/pom.xml index 99284b6..624f7b5 100644 --- a/walkguide-backend/demo/pom.xml +++ b/walkguide-backend/demo/pom.xml @@ -136,53 +136,30 @@ - org.apache.maven.plugins - maven-compiler-plugin - - - - org.projectlombok - lombok - 1.18.36 - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - - - default-compile - compile - compile - - - - org.projectlombok - lombok - 1.18.36 - - - - - - default-testCompile - test-compile - testCompile - - - - org.projectlombok - lombok - 1.18.36 - - - - - - + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + 1.18.36 + + + + + + default-compile + compile + compile + + + default-testCompile + test-compile + testCompile + + + 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 65f3d72..e1af2b1 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 @@ -114,6 +114,16 @@ public class UserController { "SOS dikirim! Guardian sudah diberitahu.")); } + @GetMapping("/sos-events") + public ResponseEntity>> getSosEvents( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(ApiResponse.ok( + sosService.getSosEvents(SecurityHelper.getCurrentUserId(), + PageRequest.of(page, size)), + "Riwayat SOS")); + } + @GetMapping("/activity-logs") public ResponseEntity>> getActivityLogs( @RequestParam(defaultValue = "0") int page, diff --git a/walkguide-backend/demo/src/main/resources/.env.example b/walkguide-backend/demo/src/main/resources/.env.example new file mode 100644 index 0000000..11acc7a --- /dev/null +++ b/walkguide-backend/demo/src/main/resources/.env.example @@ -0,0 +1,35 @@ +# =================================================== +# Profile: prod (production) +# Aktifkan dengan: --spring.profiles.active=prod +# Semua nilai WAJIB diisi via environment variable +# Tidak ada default value — akan gagal start jika kosong +# =================================================== + +spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + show-sql: false + properties: + hibernate: + format_sql: false + +server: + port: ${PORT:8080} + +jwt: + secret: ${JWT_SECRET} + expiration: ${JWT_EXPIRATION:86400000} + +agora: + app-id: ${AGORA_APP_ID} + app-certificate: ${AGORA_APP_CERTIFICATE} + +logging: + level: + com.walkguide: INFO + org.springframework.messaging: WARN + org.springframework.web.socket: WARN \ No newline at end of file diff --git a/walkguide-backend/demo/src/main/resources/application-dev.yml b/walkguide-backend/demo/src/main/resources/application-dev.yml new file mode 100644 index 0000000..57096ce --- /dev/null +++ b/walkguide-backend/demo/src/main/resources/application-dev.yml @@ -0,0 +1,31 @@ +# =================================================== +# Profile: dev (development lokal) +# Aktifkan dengan: --spring.profiles.active=dev +# atau set env: SPRING_PROFILES_ACTIVE=dev +# =================================================== + +spring: + datasource: + url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001} + username: ${DB_USERNAME:5803024001} + password: ${DB_PASSWORD:pw5803024001} + + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + +jwt: + secret: ${JWT_SECRET:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970} + expiration: 86400000 + +agora: + app-id: ${AGORA_APP_ID:} + app-certificate: ${AGORA_APP_CERTIFICATE:} + +logging: + level: + com.walkguide: DEBUG + org.springframework.messaging: DEBUG + org.springframework.web.socket: DEBUG \ No newline at end of file diff --git a/walkguide-backend/demo/src/main/resources/application-prod.yml b/walkguide-backend/demo/src/main/resources/application-prod.yml new file mode 100644 index 0000000..11acc7a --- /dev/null +++ b/walkguide-backend/demo/src/main/resources/application-prod.yml @@ -0,0 +1,35 @@ +# =================================================== +# Profile: prod (production) +# Aktifkan dengan: --spring.profiles.active=prod +# Semua nilai WAJIB diisi via environment variable +# Tidak ada default value — akan gagal start jika kosong +# =================================================== + +spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + show-sql: false + properties: + hibernate: + format_sql: false + +server: + port: ${PORT:8080} + +jwt: + secret: ${JWT_SECRET} + expiration: ${JWT_EXPIRATION:86400000} + +agora: + app-id: ${AGORA_APP_ID} + app-certificate: ${AGORA_APP_CERTIFICATE} + +logging: + level: + com.walkguide: INFO + org.springframework.messaging: WARN + org.springframework.web.socket: WARN \ No newline at end of file diff --git a/walkguide-backend/demo/src/main/resources/application.properties b/walkguide-backend/demo/src/main/resources/application.properties index b7c649f..a7b2647 100644 --- a/walkguide-backend/demo/src/main/resources/application.properties +++ b/walkguide-backend/demo/src/main/resources/application.properties @@ -1,10 +1,10 @@ # ===== SERVER ===== -server.port=8080 +server.port=${SERVER_PORT:8080} # ===== POSTGRESQL CONNECTION ===== -spring.datasource.url=jdbc:postgresql://202.46.28.160:2002/uas_5803024001 -spring.datasource.username=5803024001 -spring.datasource.password=pw5803024001 +spring.datasource.url=${DB_URL} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} spring.datasource.driver-class-name=org.postgresql.Driver # ===== JPA / HIBERNATE ===== @@ -19,24 +19,23 @@ spring.flyway.locations=classpath:db/migration spring.flyway.baseline-on-migrate=true # ===== JWT ===== -jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970 -jwt.expiration=86400000 +jwt.secret=${JWT_SECRET} +jwt.expiration=${JWT_EXPIRATION:86400000} # ===== SWAGGER ===== springdoc.swagger-ui.path=/swagger-ui.html springdoc.api-docs.path=/v3/api-docs # ===== AGORA RTC ===== -# Isi dengan nilai dari dashboard.agora.io setelah buat project -# Jika kosong: AgoraTokenService akan generate token kosong (mode demo/testing) -agora.app-id= -agora.app-certificate= +agora.app-id=${AGORA_APP_ID:} +agora.app-certificate=${AGORA_APP_CERTIFICATE:} # ===== WEBSOCKET ===== # WebSocket auto-dikonfigurasi oleh WebSocketConfig.java -# Tidak perlu config tambahan — Spring Boot auto-detect starter-websocket # ===== LOGGING ===== 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 diff --git a/walkguide-backend/demo/src/main/resources/openapi.yaml b/walkguide-backend/demo/src/main/resources/openapi.yaml index 7b028ae..f2c469a 100644 --- a/walkguide-backend/demo/src/main/resources/openapi.yaml +++ b/walkguide-backend/demo/src/main/resources/openapi.yaml @@ -183,6 +183,10 @@ paths: post: responses: "200": { description: SOS triggered } + /user/sos-events: + get: + responses: + "200": { description: User SOS history } /user/activity-logs: 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 f0d87ef..8b3d779 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/DemoApplicationTests.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/DemoApplicationTests.java @@ -1,11 +1,23 @@ package com.walkguide; +import com.walkguide.config.DataSeeder; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; -@SpringBootTest +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:postgresql://localhost:5432/walkguide_test", + "spring.datasource.username=test", + "spring.datasource.password=test", + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=none", + "jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970" +}) class DemoApplicationTests { + @MockBean + private DataSeeder dataSeeder; + @Test void contextLoads() { } diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/controller/UserControllerTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/controller/UserControllerTest.java index c393145..a012a28 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/controller/UserControllerTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/controller/UserControllerTest.java @@ -281,6 +281,26 @@ class UserControllerTest { } } + @Test + @DisplayName("GET /api/v1/user/sos-events - harus return paginated riwayat SOS user") + void getSosEvents_shouldReturn200() throws Exception { + try (MockedStatic sh = mockStatic(SecurityHelper.class)) { + sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L); + + Page page = new PageImpl<>(List.of()); + when(sosService.getSosEvents(eq(1L), any(PageRequest.class))).thenReturn(page); + + mockMvc.perform(get("/api/v1/user/sos-events") + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Riwayat SOS")); + + verify(sosService).getSosEvents(eq(1L), any(PageRequest.class)); + } + } + // ===== ACTIVITY LOGS ===== @Test diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/service/AuthServiceTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/service/AuthServiceTest.java index bd55d4c..1c45b62 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/service/AuthServiceTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/service/AuthServiceTest.java @@ -7,9 +7,11 @@ import com.walkguide.entity.RefreshToken; import com.walkguide.entity.User; import com.walkguide.entity.UserSettings; import com.walkguide.enums.ActivityLogType; +import com.walkguide.repository.HardwareShortcutRepository; import com.walkguide.repository.RefreshTokenRepository; import com.walkguide.repository.UserRepository; import com.walkguide.repository.UserSettingsRepository; +import com.walkguide.repository.VoiceCommandConfigRepository; import com.walkguide.security.JwtUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -35,6 +37,8 @@ class AuthServiceTest { @Mock UserRepository userRepository; @Mock RefreshTokenRepository refreshTokenRepository; @Mock UserSettingsRepository userSettingsRepository; + @Mock HardwareShortcutRepository hardwareShortcutRepository; + @Mock VoiceCommandConfigRepository voiceCommandConfigRepository; @Mock ActivityLogService activityLogService; @Mock JwtUtil jwtUtil; @Mock PasswordEncoder passwordEncoder; diff --git a/walkguide-mobile/walkguide_app/.gitignore b/walkguide-mobile/walkguide_app/.gitignore index 3820a95..0e03039 100644 --- a/walkguide-mobile/walkguide_app/.gitignore +++ b/walkguide-mobile/walkguide_app/.gitignore @@ -43,3 +43,9 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# JVM crash dumps +hs_err_pid*.log + +# Android SDK path (generated by Android Studio) +android/local.properties diff --git a/walkguide-mobile/walkguide_app/android/app/build.gradle.kts b/walkguide-mobile/walkguide_app/android/app/build.gradle.kts index a1771e9..ae4ef59 100644 --- a/walkguide-mobile/walkguide_app/android/app/build.gradle.kts +++ b/walkguide-mobile/walkguide_app/android/app/build.gradle.kts @@ -13,6 +13,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -24,7 +25,7 @@ android { applicationId = "com.example.walkguide_app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 26 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName @@ -42,3 +43,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/walkguide-mobile/walkguide_app/android/build.gradle.kts b/walkguide-mobile/walkguide_app/android/build.gradle.kts index dbee657..b93f306 100644 --- a/walkguide-mobile/walkguide_app/android/build.gradle.kts +++ b/walkguide-mobile/walkguide_app/android/build.gradle.kts @@ -15,6 +15,42 @@ subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) } +subprojects { + if (project.name == "agora_rtc_engine" || project.name == "iris_method_channel") { + tasks.configureEach { + if (name.startsWith("configureCMake") || name.startsWith("buildCMake")) { + doFirst { + val cmakeFile = listOf( + project.file("src/main/cpp/CMakeLists.txt"), + project.file("../src/CMakeLists.txt"), + ).firstOrNull { it.exists() } + + if (cmakeFile != null) { + val text = cmakeFile.readText() + if (!text.contains("c++_shared")) { + val patchedText = + if (text.contains("target_link_libraries")) { + text.replace( + " EGL\n )", + " EGL\n c++_shared\n )", + ) + } else { + text + """ + +target_link_libraries(${'$'}{LIBRARY_NAME} + PRIVATE + c++_shared + ) +""" + } + cmakeFile.writeText(patchedText) + } + } + } + } + } + } +} subprojects { project.evaluationDependsOn(":app") } diff --git a/walkguide-mobile/walkguide_app/android/gradle.properties b/walkguide-mobile/walkguide_app/android/gradle.properties index f018a61..7ed25f5 100644 --- a/walkguide-mobile/walkguide_app/android/gradle.properties +++ b/walkguide-mobile/walkguide_app/android/gradle.properties @@ -1,3 +1,4 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +kotlin.incremental=false diff --git a/walkguide-mobile/walkguide_app/dart_test.yaml b/walkguide-mobile/walkguide_app/dart_test.yaml new file mode 100644 index 0000000..6d8e711 --- /dev/null +++ b/walkguide-mobile/walkguide_app/dart_test.yaml @@ -0,0 +1 @@ +concurrency: 1 diff --git a/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart b/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart index 8d06ca9..8eb6dd6 100644 --- a/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart +++ b/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart @@ -1,5 +1,23 @@ enum ObstacleDirection { left, center, right } +class BoundingBox { + final double left; + final double top; + final double right; + final double bottom; + + const BoundingBox({ + required this.left, + required this.top, + required this.right, + required this.bottom, + }); + + double get width => right - left; + double get height => bottom - top; + double get centerX => left + width / 2; +} + class DetectionResult { final String label; final double confidence; @@ -26,12 +44,73 @@ class DetectionResult { } class ObstacleAnalyzer { - DetectionResult analyzeFallback({String label = 'person', double confidence = 0.86}) { + static const double frameWidth = 640.0; + static const double frameHeight = 480.0; + + ObstacleDirection analyzeDirection(BoundingBox box) { + final cx = box.centerX; + if (cx < frameWidth * 0.33) return ObstacleDirection.left; + if (cx > frameWidth * 0.67) return ObstacleDirection.right; + return ObstacleDirection.center; + } + + String estimateDistance(BoundingBox box) { + final ratio = box.height / frameHeight; + if (ratio > 0.60) return 'Very Close (< 1m)'; + if (ratio > 0.35) return 'Close (1-2m)'; + if (ratio > 0.15) return 'Medium (2-4m)'; + return 'Far (> 4m)'; + } + + String buildTtsMessage(DetectionResult result) { + final directionLabel = switch (result.direction) { + ObstacleDirection.left => 'kiri', + ObstacleDirection.center => 'depan', + ObstacleDirection.right => 'kanan', + }; + return 'Hati-hati, ${result.label} di $directionLabel. ' + 'Jarak ${result.estimatedDistance}.'; + } + + DetectionResult? prioritize(List detections) { + if (detections.isEmpty) return null; + const order = [ + 'Very Close (< 1m)', + 'Very Close', + 'Close (1-2m)', + 'Close', + 'Medium (2-4m)', + 'Medium', + 'Far (> 4m)', + 'Far', + ]; + final sorted = List.of(detections); + sorted.sort((a, b) { + final ai = order.indexOf(a.estimatedDistance); + final bi = order.indexOf(b.estimatedDistance); + final aRank = ai == -1 ? order.length : ai; + final bRank = bi == -1 ? order.length : bi; + return aRank.compareTo(bRank); + }); + return sorted.first; + } + + List filterByConfidence( + List detections, + double threshold, + ) { + return detections.where((d) => d.confidence >= threshold).toList(); + } + + DetectionResult analyzeFallback({ + String label = 'person', + double confidence = 0.86, + }) { return DetectionResult( label: label, confidence: confidence, direction: ObstacleDirection.center, - estimatedDistance: 'Close', + estimatedDistance: 'Close (1-2m)', ); } } diff --git a/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart b/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart index e16a5df..cc6cbbf 100644 --- a/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart +++ b/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart @@ -61,6 +61,7 @@ class AppConstants { await prefs.setString(_selectedYoloModelKey, path); } - // Agora - ganti dengan App ID dari agora.io - static const String agoraAppId = 'YOUR_AGORA_APP_ID'; + // Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=... + static const String agoraAppId = + String.fromEnvironment('AGORA_APP_ID', defaultValue: ''); } diff --git a/walkguide-mobile/walkguide_app/lib/core/services/call_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/call_service.dart index b26c91b..c52e5f3 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/call_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/call_service.dart @@ -20,7 +20,7 @@ class CallService { } Future getPairedReceiverId() async { - final res = await _apiClient.dio.get('/pairing/status'); + final res = await _apiClient.dio.get('/shared/pairing/status'); final data = res.data['data']; if (data is! Map) return null; final id = data['pairedWithId']; @@ -72,6 +72,10 @@ class CallService { int uid = 0, }) async { try { + if (AppConstants.agoraAppId.isEmpty) { + debugPrint('Agora join skipped: AGORA_APP_ID is not configured'); + return false; + } _engine ??= createAgoraRtcEngine(); await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId)); await _engine!.enableAudio(); diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart new file mode 100644 index 0000000..66a9363 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart @@ -0,0 +1,540 @@ +// lib/features/guardian_dashboard/guardian_activity_log_screen.dart +// ignore_for_file: use_build_context_synchronously + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; + +import '../../../app/injection_container.dart'; +import '../../../core/network/api_client.dart'; + +Dio get _api => sl().dio; + +class GuardianActivityLogScreen extends StatefulWidget { + const GuardianActivityLogScreen({super.key}); + + @override + State createState() => + _GuardianActivityLogScreenState(); +} + +class _GuardianActivityLogScreenState extends State { + List<_LogItem> _items = []; + List<_LogItem> _filtered = []; + bool _loading = true; + String? _error; + String _selectedFilter = 'ALL'; + bool _needsPairing = false; + + static const _filters = [ + 'ALL', + 'WALKGUIDE', + 'SOS', + 'AUTH', + 'OBSTACLE', + 'LOCATION', + ]; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + _needsPairing = false; + }); + try { + // Cek pairing dulu + final paired = await _hasActivePairing(); + if (!paired) { + setState(() { + _needsPairing = true; + _loading = false; + }); + return; + } + + final res = await _api.get('/guardian/activity-logs', queryParameters: { + 'size': 50, + 'page': 0 + }).timeout(const Duration(seconds: 10)); + + // Response bisa berupa list langsung atau paged {content: [...]} + final data = res.data['data']; + List list; + if (data is List) { + list = data; + } else if (data is Map && data['content'] is List) { + list = data['content'] as List; + } else { + list = []; + } + + final items = list + .whereType() + .map((e) => _LogItem.fromJson(Map.from(e))) + .toList(); + + setState(() { + _items = items; + _applyFilter(_selectedFilter); + _loading = false; + }); + } on DioException catch (e) { + setState(() { + _error = e.response?.data?['message']?.toString() ?? + 'Gagal memuat activity log.'; + _loading = false; + }); + } catch (e) { + setState(() { + _error = 'Timeout / error: $e'; + _loading = false; + }); + } + } + + 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; + } + + void _applyFilter(String filter) { + _selectedFilter = filter; + if (filter == 'ALL') { + _filtered = List.from(_items); + } else { + _filtered = _items.where((item) { + switch (filter) { + case 'WALKGUIDE': + return item.logType.contains('WALKGUIDE'); + case 'SOS': + return item.logType.contains('SOS'); + case 'AUTH': + return item.logType == 'LOGIN' || + item.logType == 'LOGOUT' || + item.logType == 'APP_OPEN' || + item.logType == 'APP_CLOSE'; + case 'OBSTACLE': + return item.logType.contains('OBSTACLE'); + case 'LOCATION': + return item.logType.contains('LOCATION') || + item.logType.contains('GEOFENCE'); + default: + return true; + } + }).toList(); + } + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────────────── + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'User Logs', + style: GoogleFonts.outfit( + fontSize: 22, + fontWeight: FontWeight.w800, + color: const Color(0xFF0F172A), + ), + ), + Text( + _needsPairing + ? 'Pairing dulu untuk melihat log' + : '${_items.length} aktivitas tercatat', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF64748B), + ), + ), + ], + ), + ), + IconButton( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh', + color: const Color(0xFF64748B), + ), + ], + ), + const SizedBox(height: 12), + + // ── Filter chips ───────────────────────────────────────────────── + if (!_needsPairing && !_loading && _error == null) + SizedBox( + height: 36, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _filters.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, i) { + final f = _filters[i]; + final selected = _selectedFilter == f; + return FilterChip( + label: Text(f), + selected: selected, + onSelected: (_) => setState(() => _applyFilter(f)), + selectedColor: + const Color(0xFF1A56DB).withValues(alpha: 0.12), + checkmarkColor: const Color(0xFF1A56DB), + labelStyle: GoogleFonts.inter( + color: selected + ? const Color(0xFF1A56DB) + : const Color(0xFF64748B), + fontWeight: + selected ? FontWeight.w700 : FontWeight.normal, + fontSize: 12, + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + side: BorderSide( + color: selected + ? const Color(0xFF1A56DB) + : const Color(0xFFE2E8F0), + ), + ); + }, + ), + ), + + if (!_needsPairing && !_loading && _error == null) + const SizedBox(height: 16), + + // ── Body ───────────────────────────────────────────────────────── + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _needsPairing + ? _buildNoPairingPanel() + : _error != null + ? _buildErrorPanel() + : _filtered.isEmpty + ? _buildEmptyPanel() + : RefreshIndicator( + onRefresh: _load, + color: const Color(0xFF1A56DB), + child: ListView.builder( + itemCount: _filtered.length, + itemBuilder: (ctx, i) => + _LogCard(item: _filtered[i]), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildNoPairingPanel() { + return Center( + child: Container( + padding: const EdgeInsets.all(24), + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFDE68A)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.link_off, color: Color(0xFFD97706), size: 52), + const SizedBox(height: 14), + Text( + 'Belum Pairing', + style: GoogleFonts.outfit( + fontSize: 18, + fontWeight: FontWeight.w700, + color: const Color(0xFF92400E), + ), + ), + const SizedBox(height: 8), + Text( + 'Hubungkan akun Guardian dengan User terlebih dahulu untuk melihat log aktivitas.', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF92400E), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildErrorPanel() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off, size: 52, color: Color(0xFF94A3B8)), + const SizedBox(height: 14), + Text( + _error!, + textAlign: TextAlign.center, + style: + GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B)), + ), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Coba lagi'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF1A56DB)), + ), + ], + ), + ); + } + + Widget _buildEmptyPanel() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.history, size: 64, color: Color(0xFF94A3B8)), + const SizedBox(height: 14), + Text( + _selectedFilter == 'ALL' + ? 'Belum ada aktivitas' + : 'Tidak ada aktivitas "$_selectedFilter"', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: const Color(0xFF94A3B8), + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// DATA MODEL +// ───────────────────────────────────────────────────────────────────────────── + +class _LogItem { + final int id; + final String logType; + final String? description; + final DateTime createdAt; + + const _LogItem({ + required this.id, + required this.logType, + this.description, + required this.createdAt, + }); + + factory _LogItem.fromJson(Map j) => _LogItem( + id: (j['id'] as num?)?.toInt() ?? 0, + logType: j['logType']?.toString() ?? 'UNKNOWN', + description: j['description']?.toString(), + createdAt: + DateTime.tryParse(j['createdAt']?.toString() ?? '')?.toLocal() ?? + DateTime.now(), + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// LOG CARD +// ───────────────────────────────────────────────────────────────────────────── + +class _LogCard extends StatelessWidget { + final _LogItem item; + const _LogCard({required this.item}); + + @override + Widget build(BuildContext context) { + final meta = _logMeta(item.logType); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timeline dot + connector line + Column( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: meta.color.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon(meta.icon, color: meta.color, size: 18), + ), + Container( + width: 1.5, + height: 22, + color: const Color(0xFFE2E8F0), + ), + ], + ), + const SizedBox(width: 12), + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + meta.label, + style: GoogleFonts.inter( + fontWeight: FontWeight.w700, + color: meta.color, + fontSize: 13, + ), + ), + ), + Text( + _formatTime(item.createdAt), + style: GoogleFonts.jetBrainsMono( + color: const Color(0xFF94A3B8), + fontSize: 11, + ), + ), + ], + ), + if (item.description != null && item.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 3), + child: Text( + item.description!, + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF64748B), + ), + ), + ), + const SizedBox(height: 14), + ], + ), + ), + ), + ], + ), + ); + } + + String _formatTime(DateTime dt) { + final now = DateTime.now(); + if (dt.day == now.day && dt.month == now.month && dt.year == now.year) { + return DateFormat('HH:mm').format(dt); + } + return DateFormat('dd MMM HH:mm').format(dt); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// LOG METADATA +// ───────────────────────────────────────────────────────────────────────────── + +class _LogMeta { + final IconData icon; + final Color color; + final String label; + const _LogMeta( + {required this.icon, required this.color, required this.label}); +} + +_LogMeta _logMeta(String logType) { + switch (logType.toUpperCase()) { + case 'LOGIN': + return const _LogMeta( + icon: Icons.login, color: Color(0xFF16A34A), label: 'Login'); + case 'LOGOUT': + return const _LogMeta( + icon: Icons.logout, color: Color(0xFF94A3B8), label: 'Logout'); + case 'APP_OPEN': + return const _LogMeta( + icon: Icons.open_in_new, + color: Color(0xFF1A56DB), + label: 'App Dibuka'); + case 'APP_CLOSE': + return const _LogMeta( + icon: Icons.close, color: Color(0xFF94A3B8), label: 'App Ditutup'); + case 'WALKGUIDE_START': + return const _LogMeta( + icon: Icons.directions_walk, + color: Color(0xFF1A56DB), + label: 'WalkGuide Mulai'); + case 'WALKGUIDE_STOP': + return const _LogMeta( + icon: Icons.stop_circle, + color: Color(0xFF94A3B8), + label: 'WalkGuide Berhenti'); + case 'OBSTACLE_DETECTED': + return const _LogMeta( + icon: Icons.warning_amber, + color: Color(0xFFD97706), + label: 'Obstacle Terdeteksi'); + case 'SOS_TRIGGERED': + return const _LogMeta( + icon: Icons.sos, color: Color(0xFFDC2626), label: 'SOS Terkirim'); + case 'SOS_ACKNOWLEDGED': + return const _LogMeta( + icon: Icons.check_circle, + color: Color(0xFF16A34A), + label: 'SOS Diakui Guardian'); + case 'CALL_INITIATED': + return const _LogMeta( + icon: Icons.call, + color: Color(0xFF16A34A), + label: 'Panggilan Dimulai'); + case 'CALL_ENDED': + return const _LogMeta( + icon: Icons.call_end, + color: Color(0xFF94A3B8), + label: 'Panggilan Selesai'); + case 'LOCATION_UPDATE': + return const _LogMeta( + icon: Icons.location_on, + color: Color(0xFF1A56DB), + label: 'Lokasi Diperbarui'); + case 'GEOFENCE_EXIT': + return const _LogMeta( + icon: Icons.fence, + color: Color(0xFFDC2626), + label: 'Keluar Area Aman'); + case 'GEOFENCE_ENTER': + return const _LogMeta( + icon: Icons.home, color: Color(0xFF16A34A), label: 'Masuk Area Aman'); + default: + return _LogMeta( + icon: Icons.circle_outlined, + color: const Color(0xFF94A3B8), + label: logType); + } +} 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 new file mode 100644 index 0000000..a99eb24 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart @@ -0,0 +1,637 @@ +// lib/features/guardian_dashboard/guardian_ai_config_screen.dart +// ignore_for_file: use_build_context_synchronously + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../app/injection_container.dart'; +import '../../../core/network/api_client.dart'; + +Dio get _api => sl().dio; + +class GuardianAiConfigScreen extends StatefulWidget { + const GuardianAiConfigScreen({super.key}); + + @override + State createState() => _GuardianAiConfigScreenState(); +} + +class _GuardianAiConfigScreenState extends State { + bool _loading = true; + bool _saving = false; + String? _error; + bool _needsPairing = false; + + // Config values + double _confidenceThreshold = 0.5; + double _alertDistanceClose = 1.5; + double _alertDistanceMedium = 3.0; + int _maxInferenceFps = 5; + String _enabledLabels = 'ALL'; + + static const _labelOptions = ['ALL', 'PERSON', 'VEHICLE', 'OBSTACLE']; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + _needsPairing = false; + }); + try { + final paired = await _hasActivePairing(); + if (!paired) { + setState(() { + _needsPairing = true; + _loading = false; + }); + return; + } + + final res = await _api + .get('/guardian/ai-config') + .timeout(const Duration(seconds: 8)); + final data = res.data['data']; + if (data is Map) { + setState(() { + _confidenceThreshold = + (data['confidenceThreshold'] as num?)?.toDouble() ?? 0.5; + _alertDistanceClose = + (data['alertDistanceClose'] as num?)?.toDouble() ?? 1.5; + _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 = e.response?.data?['message']?.toString() ?? + 'Gagal memuat konfigurasi AI.'; + }); + } catch (e) { + setState(() => _error = 'Timeout / error: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _save() async { + setState(() => _saving = true); + try { + await _api.put('/guardian/ai-config', data: { + 'confidenceThreshold': _confidenceThreshold, + 'alertDistanceClose': _alertDistanceClose, + 'alertDistanceMedium': _alertDistanceMedium, + 'maxInferenceFps': _maxInferenceFps, + 'enabledLabels': _enabledLabels, + }).timeout(const Duration(seconds: 8)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Konfigurasi AI berhasil disimpan'), + backgroundColor: Color(0xFF16A34A), + ), + ); + } + } on DioException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.response?.data?['message']?.toString() ?? + 'Gagal menyimpan konfigurasi.'), + backgroundColor: const Color(0xFFDC2626), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: const 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; + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────────────── + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AI Config', + style: GoogleFonts.outfit( + fontSize: 22, + fontWeight: FontWeight.w800, + color: const Color(0xFF0F172A), + ), + ), + Text( + 'Konfigurasi deteksi YOLO untuk User', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF64748B), + ), + ), + ], + ), + ), + IconButton( + onPressed: () => context.go('/guardian/benchmark'), + icon: const Icon(Icons.speed_outlined), + tooltip: 'Benchmark', + color: const Color(0xFF64748B), + ), + IconButton( + onPressed: _loading ? null : _load, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh', + color: const Color(0xFF64748B), + ), + ], + ), + const SizedBox(height: 16), + + // ── Body ───────────────────────────────────────────────────────── + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _needsPairing + ? _buildNoPairingPanel() + : _error != null + ? _buildErrorPanel() + : _buildConfigForm(), + ), + ], + ), + ), + ); + } + + Widget _buildConfigForm() { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Confidence Threshold ────────────────────────────────────────── + _SectionCard( + title: 'Confidence Threshold', + subtitle: + 'Minimal keyakinan AI untuk menganggap objek sebagai obstacle', + icon: Icons.tune_outlined, + iconColor: const Color(0xFF1A56DB), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Nilai saat ini:', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF64748B))), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF1A56DB).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _confidenceThreshold.toStringAsFixed(2), + style: GoogleFonts.jetBrainsMono( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF1A56DB), + ), + ), + ), + ], + ), + Slider( + value: _confidenceThreshold, + min: 0.1, + max: 0.9, + divisions: 8, + activeColor: const Color(0xFF1A56DB), + onChanged: (v) => setState(() => _confidenceThreshold = v), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('0.1 (sensitif)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + Text('0.9 (ketat)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + + // ── Alert Distances ─────────────────────────────────────────────── + _SectionCard( + title: 'Jarak Peringatan', + subtitle: 'Batas jarak (meter) untuk level peringatan', + icon: Icons.radar_outlined, + iconColor: const Color(0xFFD97706), + child: Column( + children: [ + // Close + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFDC2626), + ), + ), + const SizedBox(width: 6), + Text('Jarak Dekat', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF0F172A))), + ]), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFDC2626).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_alertDistanceClose.toStringAsFixed(1)} m', + style: GoogleFonts.jetBrainsMono( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFFDC2626), + ), + ), + ), + ], + ), + Slider( + value: _alertDistanceClose, + min: 0.5, + max: 3.0, + divisions: 5, + activeColor: const Color(0xFFDC2626), + onChanged: (v) => setState(() => _alertDistanceClose = v), + ), + const SizedBox(height: 8), + // Medium + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFD97706), + ), + ), + const SizedBox(width: 6), + Text('Jarak Sedang', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF0F172A))), + ]), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFD97706).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_alertDistanceMedium.toStringAsFixed(1)} m', + style: GoogleFonts.jetBrainsMono( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFFD97706), + ), + ), + ), + ], + ), + Slider( + value: _alertDistanceMedium, + min: 1.0, + max: 8.0, + divisions: 7, + activeColor: const Color(0xFFD97706), + onChanged: (v) => setState(() => _alertDistanceMedium = v), + ), + ], + ), + ), + const SizedBox(height: 12), + + // ── Max Inference FPS ───────────────────────────────────────────── + _SectionCard( + title: 'Max Inference FPS', + subtitle: + 'Maksimal frame per detik untuk inferensi AI (lebih tinggi = lebih boros baterai)', + icon: Icons.speed_outlined, + iconColor: const Color(0xFF059669), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('FPS saat ini:', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF64748B))), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF059669).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$_maxInferenceFps fps', + style: GoogleFonts.jetBrainsMono( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF059669), + ), + ), + ), + ], + ), + Slider( + value: _maxInferenceFps.toDouble(), + min: 1, + max: 30, + divisions: 29, + activeColor: const Color(0xFF059669), + onChanged: (v) => + setState(() => _maxInferenceFps = v.toInt()), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('1 fps (hemat baterai)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + Text('30 fps (real-time)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + + // ── Enabled Labels ──────────────────────────────────────────────── + _SectionCard( + title: 'Label yang Diaktifkan', + subtitle: 'Jenis objek yang akan dideteksi AI', + icon: Icons.label_outline, + iconColor: const Color(0xFF7C3AED), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _labelOptions.map((label) { + final selected = _enabledLabels == label; + return GestureDetector( + onTap: () => setState(() => _enabledLabels = label), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: selected ? const Color(0xFF7C3AED) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected + ? const Color(0xFF7C3AED) + : const Color(0xFFE2E8F0), + ), + ), + child: Text( + label, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: + selected ? Colors.white : const Color(0xFF64748B), + ), + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 24), + + // ── Save button ─────────────────────────────────────────────────── + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.save_outlined), + label: Text(_saving ? 'Menyimpan...' : 'Simpan Konfigurasi'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF1A56DB), + padding: const EdgeInsets.symmetric(vertical: 14), + textStyle: GoogleFonts.inter( + fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + Widget _buildNoPairingPanel() { + return Center( + child: Container( + padding: const EdgeInsets.all(24), + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFDE68A)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.link_off, color: Color(0xFFD97706), size: 52), + const SizedBox(height: 14), + Text( + 'Belum Pairing', + style: GoogleFonts.outfit( + fontSize: 18, + fontWeight: FontWeight.w700, + color: const Color(0xFF92400E), + ), + ), + const SizedBox(height: 8), + Text( + 'Hubungkan akun Guardian dengan User terlebih dahulu untuk mengatur konfigurasi AI.', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF92400E)), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildErrorPanel() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off, size: 52, color: Color(0xFF94A3B8)), + const SizedBox(height: 14), + Text( + _error!, + textAlign: TextAlign.center, + style: + GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B)), + ), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Coba lagi'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF1A56DB)), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// SECTION CARD +// ───────────────────────────────────────────────────────────────────────────── + +class _SectionCard extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final Color iconColor; + final Widget child; + + const _SectionCard({ + required this.title, + required this.subtitle, + required this.icon, + required this.iconColor, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: iconColor, size: 18), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.outfit( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A), + ), + ), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11, + color: const Color(0xFF94A3B8), + ), + ), + ], + ), + ), + ]), + const SizedBox(height: 16), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 12), + child, + ], + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart index cddbcb0..834cabc 100644 --- a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart @@ -1,10 +1,18 @@ +export '../home/presentation/guardian_dashboard_screen.dart' + show GuardianDashboardScreen; + +export 'guardian_activity_log_screen.dart' + show + GuardianActivityLogScreen; + +export 'guardian_ai_config_screen.dart' + show + GuardianAiConfigScreen; + export '../screens.dart' show - GuardianDashboardScreen, GuardianMapScreen, - GuardianActivityLogScreen, GuardianSendNotifScreen, - GuardianAiConfigScreen, GuardianVoiceCmdScreen, GuardianShortcutScreen, GuardianGeofenceScreen; diff --git a/walkguide-mobile/walkguide_app/lib/features/screens.dart b/walkguide-mobile/walkguide_app/lib/features/screens.dart index b709804..f086e75 100644 --- a/walkguide-mobile/walkguide_app/lib/features/screens.dart +++ b/walkguide-mobile/walkguide_app/lib/features/screens.dart @@ -28,6 +28,8 @@ import '../core/services/tts_service.dart'; import '../core/services/websocket_service.dart'; import '../core/storage/secure_storage.dart'; +export 'guardian_dashboard/guardian_screens.dart'; + Dio get _api => sl().dio; class ServerConnectScreen extends StatefulWidget { @@ -779,26 +781,12 @@ class IncomingCallScreen extends StatelessWidget { text: 'Accept or reject incoming guardian calls here.'); } -class GuardianDashboardScreen extends StatelessWidget { - const GuardianDashboardScreen({super.key}); - @override - Widget build(BuildContext context) => const _EndpointListScreen( - title: 'Guardian Dashboard', endpoint: '/guardian/dashboard'); -} - class GuardianMapScreen extends StatelessWidget { const GuardianMapScreen({super.key}); @override Widget build(BuildContext context) => const _GuardianMapHistoryScreen(); } -class GuardianActivityLogScreen extends StatelessWidget { - const GuardianActivityLogScreen({super.key}); - @override - Widget build(BuildContext context) => const _EndpointListScreen( - title: 'User Logs', endpoint: '/guardian/activity-logs'); -} - class GuardianSendNotifScreen extends StatefulWidget { const GuardianSendNotifScreen({super.key}); @@ -850,23 +838,6 @@ class _GuardianSendNotifScreenState extends State { } } -class GuardianAiConfigScreen extends StatelessWidget { - const GuardianAiConfigScreen({super.key}); - @override - Widget build(BuildContext context) { - return _Page( - title: 'AI Config', - subtitle: '/guardian/ai-config', - actions: [ - IconButton( - onPressed: () => context.go('/guardian/benchmark'), - icon: const Icon(Icons.speed)) - ], - child: const _EndpointList(endpoint: '/guardian/ai-config'), - ); - } -} - class GuardianVoiceCmdScreen extends StatelessWidget { const GuardianVoiceCmdScreen({super.key}); @override @@ -977,7 +948,7 @@ class _GuardianMapHistoryScreen extends StatelessWidget { child: _MapScreenBody(guardianEndpoint: '/guardian/user-location'), ), SizedBox(height: 12), - Expanded(flex: 2, child: _LocationTimeline()), + Expanded(flex: 2, child: ClipRect(child: _LocationTimeline())), ], ), ); @@ -1872,8 +1843,8 @@ class _EmptyPanel extends StatelessWidget { Widget build(BuildContext context) { return Container( width: double.infinity, - constraints: const BoxConstraints(minHeight: 180), - padding: const EdgeInsets.all(18), + constraints: const BoxConstraints(minHeight: 0), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), decoration: BoxDecoration( color: const Color(0xFFF8FAFC), borderRadius: BorderRadius.circular(12), @@ -2196,15 +2167,17 @@ class _JsonCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - width: double.infinity, - constraints: const BoxConstraints(minHeight: 220), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0))), - child: SingleChildScrollView(child: Text(data?.toString() ?? 'No data')), + return Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0))), + child: + SingleChildScrollView(child: Text(data?.toString() ?? 'No data')), + ), ); } } diff --git a/walkguide-mobile/walkguide_app/pubspec.lock b/walkguide-mobile/walkguide_app/pubspec.lock index a5f4313..0905ef5 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: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mgrs_dart: dependency: transitive description: @@ -1264,13 +1264,13 @@ packages: source: hosted version: "1.2.2" record_linux: - dependency: transitive + dependency: "direct overridden" description: name: record_linux - sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3" + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "1.3.0" record_platform_interface: dependency: transitive description: @@ -1592,26 +1592,26 @@ packages: dependency: transitive description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.12" tflite_flutter: dependency: "direct main" description: diff --git a/walkguide-mobile/walkguide_app/pubspec.yaml b/walkguide-mobile/walkguide_app/pubspec.yaml index deeaed7..ed373cc 100644 --- a/walkguide-mobile/walkguide_app/pubspec.yaml +++ b/walkguide-mobile/walkguide_app/pubspec.yaml @@ -91,6 +91,9 @@ dev_dependencies: mockito: ^5.4.4 bloc_test: ^9.1.7 +dependency_overrides: + record_linux: ^1.3.0 + flutter: uses-material-design: true assets: diff --git a/walkguide-mobile/walkguide_app/test/integration_test/app_flow_test.dart b/walkguide-mobile/walkguide_app/test/integration_test/app_flow_test.dart index 58c9943..b75d9ee 100644 --- a/walkguide-mobile/walkguide_app/test/integration_test/app_flow_test.dart +++ b/walkguide-mobile/walkguide_app/test/integration_test/app_flow_test.dart @@ -127,11 +127,10 @@ class _AppState extends ChangeNotifier { notifyListeners(); } - Future sendSos() async { - await Future.delayed(const Duration(milliseconds: 200)); - _sosSent = true; - notifyListeners(); - } + void sendSos() { + _sosSent = true; + notifyListeners(); + } void markAllRead() { _notifications = _notifications diff --git a/walkguide-mobile/walkguide_app/test/integration_test/flow_2_walkguide_start_stop_sos_test.dart b/walkguide-mobile/walkguide_app/test/integration_test/flow_2_walkguide_start_stop_sos_test.dart index 168654c..b52eb28 100644 --- a/walkguide-mobile/walkguide_app/test/integration_test/flow_2_walkguide_start_stop_sos_test.dart +++ b/walkguide-mobile/walkguide_app/test/integration_test/flow_2_walkguide_start_stop_sos_test.dart @@ -60,14 +60,11 @@ class _AppState extends ChangeNotifier { notifyListeners(); } - Future startWalkGuide() async { - _walkGuideStatus = _WalkGuideStatus.active; - notifyListeners(); - // Simulasi obstacle terdeteksi setelah 300ms - await Future.delayed(const Duration(milliseconds: 300)); - _detectedObstacles = ['person (87%)', 'motorcycle (72%)']; - notifyListeners(); - } + Future startWalkGuide() async { + _walkGuideStatus = _WalkGuideStatus.active; + _detectedObstacles = ['person (87%)', 'motorcycle (72%)']; + notifyListeners(); + } void stopWalkGuide() { _walkGuideStatus = _WalkGuideStatus.idle; @@ -80,11 +77,10 @@ class _AppState extends ChangeNotifier { notifyListeners(); } - Future sendSos() async { - await Future.delayed(const Duration(milliseconds: 150)); - _sosStatus = _SosStatus.triggered; - notifyListeners(); - } + Future sendSos() async { + _sosStatus = _SosStatus.triggered; + notifyListeners(); + } void goBack() { if (_currentScreen == 'walkguide' || _currentScreen == 'sos') { diff --git a/walkguide-mobile/walkguide_app/test/integration_test/flow_3_notification_read_all_test.dart b/walkguide-mobile/walkguide_app/test/integration_test/flow_3_notification_read_all_test.dart index f17730e..5b99d19 100644 --- a/walkguide-mobile/walkguide_app/test/integration_test/flow_3_notification_read_all_test.dart +++ b/walkguide-mobile/walkguide_app/test/integration_test/flow_3_notification_read_all_test.dart @@ -101,11 +101,10 @@ class _AppState extends ChangeNotifier { } } - Future markAllAsRead() async { - await Future.delayed(const Duration(milliseconds: 150)); - for (final n in _notifications) { - n.isRead = true; - } + Future markAllAsRead() async { + for (final n in _notifications) { + n.isRead = true; + } notifyListeners(); } } @@ -221,39 +220,42 @@ class _DashboardScreen extends StatelessWidget { Widget build(BuildContext context) { final unread = state.unreadCount; return Scaffold( - appBar: AppBar( - title: const Text('Dashboard'), - actions: [ - Stack( - alignment: Alignment.topRight, - children: [ - IconButton( - key: const Key('notifIconButton'), - icon: const Icon(Icons.notifications), - tooltip: 'Notifikasi', - onPressed: state.openNotifications, - ), - if (unread > 0) - Positioned( - right: 8, - top: 8, - child: Container( - key: const Key('dashboardBadge'), - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - child: Text( - '$unread', - style: const TextStyle(color: Colors.white, fontSize: 10), - ), - ), - ), - ], - ), - ], - ), + appBar: AppBar( + title: const Text('Dashboard'), + actions: [ + IconButton( + key: const Key('notifIconButton'), + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.notifications), + if (unread > 0) + Positioned( + right: -4, + top: -4, + child: IgnorePointer( + child: Container( + key: const Key('dashboardBadge'), + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: Text( + '$unread', + style: const TextStyle( + color: Colors.white, fontSize: 10), + ), + ), + ), + ), + ], + ), + tooltip: 'Notifikasi', + onPressed: state.openNotifications, + ), + ], + ), body: Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ const Text('Selamat datang di Dashboard'), diff --git a/walkguide-mobile/walkguide_app/test/unit/login_use_case_test.dart b/walkguide-mobile/walkguide_app/test/unit/login_use_case_test.dart index 515a741..849761c 100644 --- a/walkguide-mobile/walkguide_app/test/unit/login_use_case_test.dart +++ b/walkguide-mobile/walkguide_app/test/unit/login_use_case_test.dart @@ -47,7 +47,19 @@ abstract class AuthRepository { // File mock di-generate via: flutter pub run build_runner build // Untuk demo tanpa build_runner, kita buat manual mock di bawah -class MockAuthRepository extends Mock implements AuthRepository {} +class MockAuthRepository extends Mock implements AuthRepository { + @override + Future> login(String? email, String? password) => + super.noSuchMethod( + Invocation.method(#login, [email, password]), + returnValue: Future>.value( + const Left(AuthFailure('Repository belum di-stub')), + ), + returnValueForMissingStub: Future>.value( + const Left(AuthFailure('Repository belum di-stub')), + ), + ) as Future>; +} // ---------- Use case ---------- diff --git a/walkguide-mobile/walkguide_app/test/unit/obstacle_analyzer_test.dart b/walkguide-mobile/walkguide_app/test/unit/obstacle_analyzer_test.dart index fd980ea..a142e9d 100644 --- a/walkguide-mobile/walkguide_app/test/unit/obstacle_analyzer_test.dart +++ b/walkguide-mobile/walkguide_app/test/unit/obstacle_analyzer_test.dart @@ -98,19 +98,20 @@ class ObstacleAnalyzer { }; /// Pilih obstacle paling prioritas (Very Close > Close > Medium > Far). - DetectionResult? prioritize(List detections) { - if (detections.isEmpty) return null; - const order = [ - 'Very Close (< 1m)', - 'Close (1-2m)', - 'Medium (2-4m)', - 'Far (> 4m)', - ]; - detections.sort((a, b) => order - .indexOf(a.estimatedDistance) - .compareTo(order.indexOf(b.estimatedDistance))); - return detections.first; - } + DetectionResult? prioritize(List detections) { + if (detections.isEmpty) return null; + const order = [ + 'Very Close (< 1m)', + 'Close (1-2m)', + 'Medium (2-4m)', + 'Far (> 4m)', + ]; + final sorted = List.of(detections); + sorted.sort((a, b) => order + .indexOf(a.estimatedDistance) + .compareTo(order.indexOf(b.estimatedDistance))); + return sorted.first; + } /// Filter deteksi berdasarkan confidence threshold. List filterByConfidence( diff --git a/walkguide-mobile/walkguide_app/test/unit/register_use_case_test.dart b/walkguide-mobile/walkguide_app/test/unit/register_use_case_test.dart index 2c17f25..94988d9 100644 --- a/walkguide-mobile/walkguide_app/test/unit/register_use_case_test.dart +++ b/walkguide-mobile/walkguide_app/test/unit/register_use_case_test.dart @@ -48,7 +48,33 @@ abstract class RegisterRepository { }); } -class MockRegisterRepository extends Mock implements RegisterRepository {} +class MockRegisterRepository extends Mock implements RegisterRepository { + @override + Future> register({ + String? email, + String? password, + String? displayName, + String? role, + }) => + super.noSuchMethod( + Invocation.method( + #register, + const [], + { + #email: email, + #password: password, + #displayName: displayName, + #role: role, + }, + ), + returnValue: Future>.value( + const Left(ServerFailure('Repository belum di-stub')), + ), + returnValueForMissingStub: Future>.value( + const Left(ServerFailure('Repository belum di-stub')), + ), + ) as Future>; +} // ---------- Use case ---------- diff --git a/walkguide-mobile/walkguide_app/test/widget/login_screen_test.dart b/walkguide-mobile/walkguide_app/test/widget/login_screen_test.dart index e3e7770..ea589c9 100644 --- a/walkguide-mobile/walkguide_app/test/widget/login_screen_test.dart +++ b/walkguide-mobile/walkguide_app/test/widget/login_screen_test.dart @@ -261,13 +261,13 @@ void main() { testWidgets('password tersembunyi (obscureText=true) secara default', (tester) async { await tester.pumpWidget(makeTestable(const _StubLoginScreen())); - final editableText = tester.widget( - find.descendant( - of: find.byKey(const Key('password_field')), - matching: find.byType(EditableText), - ), - ); - expect(editableText.obscureText, isTrue); + final editableText = tester.widget( + find.descendant( + of: find.byKey(const Key('password_field')), + matching: find.byType(EditableText), + ), + ); + expect(editableText.obscureText, isTrue); }); testWidgets('tap toggle mengubah obscureText menjadi false', (tester) async { @@ -282,7 +282,7 @@ void main() { matching: find.byType(EditableText), ), ); - expect(editableText.obscureText, isTrue); + expect(editableText.obscureText, isFalse); }); testWidgets('tap toggle dua kali mengembalikan obscureText ke true', (tester) async { @@ -313,9 +313,10 @@ void main() { await tester.enterText(find.byKey(const Key('password_field')), 'password123'); await tester.tap(find.byKey(const Key('login_button'))); await tester.pump(); // Trigger rebuild - - expect(find.byKey(const Key('loading_indicator')), findsOneWidget); - }); + + expect(find.byKey(const Key('loading_indicator')), findsOneWidget); + await tester.pumpAndSettle(); + }); testWidgets('menyembunyikan tombol login saat loading', (tester) async { await tester.pumpWidget(makeTestable(const _StubLoginScreen())); @@ -324,9 +325,10 @@ void main() { await tester.enterText(find.byKey(const Key('password_field')), 'password123'); await tester.tap(find.byKey(const Key('login_button'))); await tester.pump(); - - expect(find.byKey(const Key('login_button')), findsNothing); - }); + + expect(find.byKey(const Key('login_button')), findsNothing); + await tester.pumpAndSettle(); + }); testWidgets('loading selesai setelah async operation', (tester) async { await tester.pumpWidget(makeTestable(const _StubLoginScreen())); diff --git a/walkguide-mobile/walkguide_app/test/widget/manual_screen_test.dart b/walkguide-mobile/walkguide_app/test/widget/manual_screen_test.dart index ceac807..51dd5d0 100644 --- a/walkguide-mobile/walkguide_app/test/widget/manual_screen_test.dart +++ b/walkguide-mobile/walkguide_app/test/widget/manual_screen_test.dart @@ -363,11 +363,16 @@ class _StubManualScreenState extends State<_StubManualScreen> { } } -Widget makeTestable(Widget child) => MaterialApp(home: child); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- +Widget makeTestable(Widget child) => MaterialApp(home: child); + +Finder _commandScrollable() => find.descendant( + of: find.byKey(const Key('command_list')), + matching: find.byType(Scrollable), + ); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- void main() { group('ManualScreen Widget Tests', () { @@ -418,12 +423,13 @@ void main() { group('Konten perintah suara', () { testWidgets('menampilkan tile untuk perintah Open Walkguide', (tester) async { - await tester.pumpWidget(makeTestable(const _StubManualScreen())); - await tester.scrollUntilVisible( - find.byKey(const Key('cmd_tile_openWalkguide')), - 200, - ); - expect(find.byKey(const Key('cmd_tile_openWalkguide')), findsOneWidget); + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + await tester.scrollUntilVisible( + find.byKey(const Key('cmd_tile_openWalkguide')), + 200, + scrollable: _commandScrollable(), + ); + expect(find.byKey(const Key('cmd_tile_openWalkguide')), findsOneWidget); }); testWidgets('menampilkan phrase perintah dalam tanda kutip', @@ -449,25 +455,36 @@ void main() { expect(find.text('"Call Guardian"'), findsOneWidget); }); - testWidgets('menampilkan perintah Send SOS', (tester) async { - await tester.pumpWidget(makeTestable(const _StubManualScreen())); - await tester.scrollUntilVisible(find.text('"Send SOS"'), 200); - expect(find.text('"Send SOS"'), findsOneWidget); - }); - - testWidgets('menampilkan perintah Where Am I', (tester) async { - await tester.pumpWidget(makeTestable(const _StubManualScreen())); - await tester.scrollUntilVisible(find.text('"Where Am I"'), 200); - expect(find.text('"Where Am I"'), findsOneWidget); - }); + testWidgets('menampilkan perintah Send SOS', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + await tester.scrollUntilVisible( + find.text('"Send SOS"'), + 200, + scrollable: _commandScrollable(), + ); + expect(find.text('"Send SOS"'), findsOneWidget); + }); + + testWidgets('menampilkan perintah Where Am I', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + await tester.scrollUntilVisible( + find.text('"Where Am I"'), + 200, + scrollable: _commandScrollable(), + ); + expect(find.text('"Where Am I"'), findsOneWidget); + }); testWidgets('menampilkan kategori Darurat untuk Send SOS', (tester) async { - await tester.pumpWidget(makeTestable(const _StubManualScreen())); - await tester.scrollUntilVisible( - find.byKey(const Key('cmd_category_sendSos')), 200); - expect(find.byKey(const Key('cmd_category_sendSos')), findsOneWidget); - }); + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + await tester.scrollUntilVisible( + find.byKey(const Key('cmd_category_sendSos')), + 200, + scrollable: _commandScrollable(), + ); + expect(find.byKey(const Key('cmd_category_sendSos')), findsOneWidget); + }); }); // ── Dialog info ─────────────────────────────────────────────────────── diff --git a/walkguide-mobile/walkguide_app/test/widget/notification_screen_test.dart b/walkguide-mobile/walkguide_app/test/widget/notification_screen_test.dart index 0d20977..80dd7c6 100644 --- a/walkguide-mobile/walkguide_app/test/widget/notification_screen_test.dart +++ b/walkguide-mobile/walkguide_app/test/widget/notification_screen_test.dart @@ -103,15 +103,16 @@ class _StubNotificationScreenState extends State<_StubNotificationScreen> { ], ], ), - actions: [ - if (_items.any((e) => !e.isRead)) - TextButton( - key: const Key('mark_all_read_button'), - onPressed: _markingAll ? null : _markAllRead, - child: const Text('Tandai Semua Dibaca'), - ), - ], - ), + actions: [ + if (_items.any((e) => !e.isRead)) + IconButton( + key: const Key('mark_all_read_button'), + onPressed: _markingAll ? null : _markAllRead, + tooltip: 'Tandai Semua Dibaca', + icon: const Icon(Icons.done_all), + ), + ], + ), body: widget.isLoading ? const Center( child: CircularProgressIndicator(key: Key('loading_indicator'))) diff --git a/walkguide-mobile/walkguide_app/test/widget/sos_screen_test.dart b/walkguide-mobile/walkguide_app/test/widget/sos_screen_test.dart index 461fc17..656a7a4 100644 --- a/walkguide-mobile/walkguide_app/test/widget/sos_screen_test.dart +++ b/walkguide-mobile/walkguide_app/test/widget/sos_screen_test.dart @@ -431,9 +431,10 @@ void main() { await tester.tap(find.byKey(const Key('sos_button'))); await tester.pump(); - - expect(find.byKey(const Key('sending_indicator')), findsOneWidget); - }); + + expect(find.byKey(const Key('sending_indicator')), findsOneWidget); + await tester.pumpAndSettle(); + }); testWidgets('setelah SOS terkirim, tampil success banner', (tester) async {