Merge pull request 'main' (#1) from 5803024019/Final-08-Evan-Jap-Bambang-WalkGuide-AI:main into main

Reviewed-on: 2526-mobile-programming/Final-08-Evan-Jap-Bambang-WalkGuide-AI#1
This commit is contained in:
5803024019 2026-05-19 06:23:02 -04:00
commit 9ab8363041
42 changed files with 4760 additions and 274 deletions

3
.gitignore vendored
View File

@ -37,3 +37,6 @@ build/
### VS Code ###
.vscode/
.env
*.env

View File

@ -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 V1V16):
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

View File

@ -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<DetectionResult>\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<NotificationResponse>
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<VoiceCommandResponse>
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<ActivityLogResponse>
AppGuardian -> Backend : GET /api/v1/guardian/obstacle-logs?page=0&size=20
Backend --> AppGuardian : 200 OK Page<ObstacleLogResponse>
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

View File

@ -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

View File

@ -0,0 +1,176 @@
@startuml
left to right direction
skinparam packageStyle rectangle
skinparam rectangle {
BackgroundColor<<actor>> 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 <<actor>>
rectangle "Spring Boot\nBackend" as Backend <<actor>>
rectangle "Firebase FCM" as FCM <<actor>>
rectangle "Agora RTC" as Agora <<actor>>
' ==========================================
' 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 : <<include>>
UC_Register ..> UC_JWT : <<include>>
UC_JWT ..> UC_RBAC : <<include>>
UC_Walk ..> UC_Detect : <<include>>
UC_Detect ..> UC_Analyze : <<include>>
UC_Analyze <.. UC_TTS : <<extend>>
UC_Analyze <.. UC_Vib : <<extend>>
UC_Detect ..> UC_ObsLog : <<include>>
UC_Walk ..> UC_Location : <<include>>
UC_Walk <.. UC_Voice : <<extend>>
UC_SOS <.. UC_Voice : <<extend>>
UC_Call <.. UC_Voice : <<extend>>
UC_Walk <.. UC_Hardware : <<extend>>
UC_SOS <.. UC_Hardware : <<extend>>
UC_Call <.. UC_Hardware : <<extend>>
UC_SOS ..> UC_FCMReceive : <<include>>
UC_SendNotif ..> UC_FCMReceive : <<include>>
UC_FCMReceive <.. UC_TTS : <<extend>>
UC_Call <.. UC_IncomingCall : <<extend>>
UC_Geofence <.. UC_FCMReceive : <<extend>>
' ==========================================
' 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

1471
report/main.tex Normal file

File diff suppressed because it is too large Load Diff

View File

@ -136,53 +136,30 @@
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>default-compile</id>
<phase>compile</phase>
<goals><goal>compile</goal></goals>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
<execution>
<id>default-testCompile</id>
<phase>test-compile</phase>
<goals><goal>testCompile</goal></goals>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
</executions>
</plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
</path>
</annotationProcessorPaths>
</configuration>
<executions>
<execution>
<id>default-compile</id>
<phase>compile</phase>
<goals><goal>compile</goal></goals>
</execution>
<execution>
<id>default-testCompile</id>
<phase>test-compile</phase>
<goals><goal>testCompile</goal></goals>
</execution>
</executions>
</plugin>
<!-- JACOCO - Code Coverage >=70% -->
<plugin>

View File

@ -114,6 +114,16 @@ public class UserController {
"SOS dikirim! Guardian sudah diberitahu."));
}
@GetMapping("/sos-events")
public ResponseEntity<ApiResponse<Page<SosEventResponse>>> 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<ApiResponse<Page<ActivityLogResponse>>> getActivityLogs(
@RequestParam(defaultValue = "0") int page,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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() {
}

View File

@ -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<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
Page<SosEventResponse> 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

View File

@ -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;

View File

@ -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

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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

View File

@ -0,0 +1 @@
concurrency: 1

View File

@ -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<DetectionResult> 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<DetectionResult>.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<DetectionResult> filterByConfidence(
List<DetectionResult> 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)',
);
}
}

View File

@ -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: '');
}

View File

@ -20,7 +20,7 @@ class CallService {
}
Future<int?> 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<String, dynamic>) 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();

View File

@ -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<ApiClient>().dio;
class GuardianActivityLogScreen extends StatefulWidget {
const GuardianActivityLogScreen({super.key});
@override
State<GuardianActivityLogScreen> createState() =>
_GuardianActivityLogScreenState();
}
class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
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<void> _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<dynamic> 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>()
.map((e) => _LogItem.fromJson(Map<String, dynamic>.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<bool> _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<String, dynamic> 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);
}
}

View File

@ -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<ApiClient>().dio;
class GuardianAiConfigScreen extends StatefulWidget {
const GuardianAiConfigScreen({super.key});
@override
State<GuardianAiConfigScreen> createState() => _GuardianAiConfigScreenState();
}
class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
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<void> _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<void> _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<bool> _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,
],
),
);
}
}

View File

@ -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;

View File

@ -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<ApiClient>().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<GuardianSendNotifScreen> {
}
}
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')),
),
);
}
}

View File

@ -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:

View File

@ -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:

View File

@ -127,11 +127,10 @@ class _AppState extends ChangeNotifier {
notifyListeners();
}
Future<void> sendSos() async {
await Future.delayed(const Duration(milliseconds: 200));
_sosSent = true;
notifyListeners();
}
void sendSos() {
_sosSent = true;
notifyListeners();
}
void markAllRead() {
_notifications = _notifications

View File

@ -60,14 +60,11 @@ class _AppState extends ChangeNotifier {
notifyListeners();
}
Future<void> 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<void> 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<void> sendSos() async {
await Future.delayed(const Duration(milliseconds: 150));
_sosStatus = _SosStatus.triggered;
notifyListeners();
}
Future<void> sendSos() async {
_sosStatus = _SosStatus.triggered;
notifyListeners();
}
void goBack() {
if (_currentScreen == 'walkguide' || _currentScreen == 'sos') {

View File

@ -101,11 +101,10 @@ class _AppState extends ChangeNotifier {
}
}
Future<void> markAllAsRead() async {
await Future.delayed(const Duration(milliseconds: 150));
for (final n in _notifications) {
n.isRead = true;
}
Future<void> 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'),

View File

@ -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<Either<Failure, UserEntity>> login(String? email, String? password) =>
super.noSuchMethod(
Invocation.method(#login, [email, password]),
returnValue: Future<Either<Failure, UserEntity>>.value(
const Left(AuthFailure('Repository belum di-stub')),
),
returnValueForMissingStub: Future<Either<Failure, UserEntity>>.value(
const Left(AuthFailure('Repository belum di-stub')),
),
) as Future<Either<Failure, UserEntity>>;
}
// ---------- Use case ----------

View File

@ -98,19 +98,20 @@ class ObstacleAnalyzer {
};
/// Pilih obstacle paling prioritas (Very Close > Close > Medium > Far).
DetectionResult? prioritize(List<DetectionResult> 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<DetectionResult> detections) {
if (detections.isEmpty) return null;
const order = [
'Very Close (< 1m)',
'Close (1-2m)',
'Medium (2-4m)',
'Far (> 4m)',
];
final sorted = List<DetectionResult>.of(detections);
sorted.sort((a, b) => order
.indexOf(a.estimatedDistance)
.compareTo(order.indexOf(b.estimatedDistance)));
return sorted.first;
}
/// Filter deteksi berdasarkan confidence threshold.
List<DetectionResult> filterByConfidence(

View File

@ -48,7 +48,33 @@ abstract class RegisterRepository {
});
}
class MockRegisterRepository extends Mock implements RegisterRepository {}
class MockRegisterRepository extends Mock implements RegisterRepository {
@override
Future<Either<Failure, UserEntity>> 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<Either<Failure, UserEntity>>.value(
const Left(ServerFailure('Repository belum di-stub')),
),
returnValueForMissingStub: Future<Either<Failure, UserEntity>>.value(
const Left(ServerFailure('Repository belum di-stub')),
),
) as Future<Either<Failure, UserEntity>>;
}
// ---------- Use case ----------

View File

@ -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<EditableText>(
find.descendant(
of: find.byKey(const Key('password_field')),
matching: find.byType(EditableText),
),
);
expect(editableText.obscureText, isTrue);
final editableText = tester.widget<EditableText>(
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()));

View File

@ -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

View File

@ -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')))

View File

@ -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 {