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:
commit
9ab8363041
3
.gitignore
vendored
3
.gitignore
vendored
@ -37,3 +37,6 @@ build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
.env
|
||||
*.env
|
||||
|
||||
Binary file not shown.
BIN
ooad-docs/diagrams/Er Diagram WalkGuide RILL FIX.drawio.pdf
Normal file
BIN
ooad-docs/diagrams/Er Diagram WalkGuide RILL FIX.drawio.pdf
Normal file
Binary file not shown.
@ -1,35 +1,542 @@
|
||||
@startuml
|
||||
@startuml WalkGuide_Component_Diagram
|
||||
title Component Diagram — WalkGuide System (v2.0)
|
||||
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
component "Flutter Mobile App" {
|
||||
[Presentation Screens]
|
||||
[ApiClient + Interceptors]
|
||||
[TTS/STT/YOLO/Location Services]
|
||||
[WebSocketService]
|
||||
[CallService]
|
||||
skinparam backgroundColor #FAFAFA
|
||||
skinparam component {
|
||||
BackgroundColor #EEF2FF
|
||||
BorderColor #6366F1
|
||||
FontColor #1E1B4B
|
||||
FontSize 11
|
||||
}
|
||||
skinparam package {
|
||||
BackgroundColor #F0F4FF
|
||||
BorderColor #818CF8
|
||||
FontColor #1E1B4B
|
||||
FontSize 12
|
||||
FontStyle bold
|
||||
}
|
||||
skinparam interface {
|
||||
BackgroundColor #DBEAFE
|
||||
BorderColor #3B82F6
|
||||
}
|
||||
skinparam database {
|
||||
BackgroundColor #FEF3C7
|
||||
BorderColor #D97706
|
||||
}
|
||||
skinparam cloud {
|
||||
BackgroundColor #F0FDF4
|
||||
BorderColor #16A34A
|
||||
}
|
||||
skinparam arrow {
|
||||
Color #4B5563
|
||||
FontSize 9
|
||||
FontColor #374151
|
||||
}
|
||||
skinparam note {
|
||||
BackgroundColor #FFFBEB
|
||||
BorderColor #F59E0B
|
||||
FontSize 9
|
||||
}
|
||||
|
||||
component "Spring Boot Backend" {
|
||||
[Controllers]
|
||||
[Services]
|
||||
[Repositories]
|
||||
[Security/JWT]
|
||||
[WebSocket Broker]
|
||||
' ─────────────────────────────────────────────
|
||||
' MOBILE DEVICE — Flutter App
|
||||
' ─────────────────────────────────────────────
|
||||
package "Mobile Device (Flutter App)" as FLUTTER #E8EAF6 {
|
||||
|
||||
package "Presentation Layer" as PRESENTATION {
|
||||
[ServerConnectScreen] as SCS
|
||||
[SplashScreen] as SS
|
||||
[LoginScreen] as LS
|
||||
[RegisterScreen] as RS
|
||||
|
||||
package "User Screens" as US_SCREENS {
|
||||
[WalkGuideScreen] as WGS
|
||||
[SosScreen] as SOSS
|
||||
[ActivityLogScreen] as ALS
|
||||
[NotificationScreen] as NS
|
||||
[NavigationModeScreen] as NMS
|
||||
[UserSettingsScreen] as USS
|
||||
[CallScreen] as CS
|
||||
[ManualScreen] as MS
|
||||
[UserPairingScreen] as UPS
|
||||
}
|
||||
|
||||
package "Guardian Screens" as GS_SCREENS {
|
||||
[GuardianDashboardScreen] as GDS
|
||||
[GuardianMapScreen] as GMS
|
||||
[GuardianActivityLogScreen] as GALS
|
||||
[GuardianSendNotifScreen] as GSNS
|
||||
[GuardianAiConfigScreen] as GACS
|
||||
[GuardianVoiceCmdScreen] as GVCS
|
||||
[GuardianShortcutScreen] as GSHS
|
||||
[GuardianGeofenceScreen] as GGFS
|
||||
[GuardianPairingScreen] as GPS
|
||||
[IncomingCallScreen] as ICS
|
||||
}
|
||||
}
|
||||
|
||||
package "Application Layer (BLoC / Cubit)" as BLOC_LAYER {
|
||||
[AuthBloc] as AuthBloc
|
||||
[PairingBloc] as PairingBloc
|
||||
[WalkGuideBloc] as WGBloc
|
||||
[SosBloc] as SosBloc
|
||||
[LocationBloc] as LocBloc
|
||||
[NotificationBloc] as NotifBloc
|
||||
[ActivityLogBloc] as ActLogBloc
|
||||
[VoiceCommandBloc] as VCBloc
|
||||
[ServerConnectBloc] as SCBloc
|
||||
[CallBloc] as CallBloc
|
||||
[GuardianDashboardBloc] as GDBloc
|
||||
}
|
||||
|
||||
package "Domain Layer" as DOMAIN {
|
||||
package "Use Cases" as USECASES {
|
||||
[LoginUseCase] as LoginUC
|
||||
[RegisterUseCase] as RegUC
|
||||
[LogoutUseCase] as LogoutUC
|
||||
[InviteUserUseCase] as InviteUC
|
||||
[RespondPairingUseCase] as RespUC
|
||||
[UnpairUseCase] as UnpairUC
|
||||
[StartSessionUseCase] as StartUC
|
||||
[StopSessionUseCase] as StopUC
|
||||
[LogObstacleUseCase] as LogObsUC
|
||||
[TriggerSosUseCase] as TrigSosUC
|
||||
[UpdateLocationUseCase] as UpdLocUC
|
||||
}
|
||||
package "Repository Interfaces" as REPO_INTF {
|
||||
interface "IAuthRepository" as IAuthRepo
|
||||
interface "IPairingRepository" as IPairRepo
|
||||
interface "IWalkGuideRepository" as IWGRepo
|
||||
interface "IActivityLogRepository" as IActRepo
|
||||
interface "ISosRepository" as ISosRepo
|
||||
interface "ILocationRepository" as ILocRepo
|
||||
interface "INotificationRepository" as INotifRepo
|
||||
interface "IAiConfigRepository" as IAIRepo
|
||||
}
|
||||
}
|
||||
|
||||
package "Data Layer" as DATA {
|
||||
package "Repository Implementations" as REPO_IMPL {
|
||||
[AuthRepositoryImpl] as AuthRepoImpl
|
||||
[PairingRepositoryImpl] as PairRepoImpl
|
||||
[WalkGuideRepositoryImpl] as WGRepoImpl
|
||||
[ActivityLogRepositoryImpl] as ActRepoImpl
|
||||
[SosRepositoryImpl] as SosRepoImpl
|
||||
[LocationRepositoryImpl] as LocRepoImpl
|
||||
[NotificationRepositoryImpl] as NotifRepoImpl
|
||||
[AiConfigRepositoryImpl] as AIRepoImpl
|
||||
}
|
||||
|
||||
package "Remote Data Sources" as REMOTE_DS {
|
||||
[AuthRemoteDataSource] as AuthDS
|
||||
[WalkGuideRemoteDataSource] as WGDS
|
||||
[ActivityLogRemoteDataSource] as ActDS
|
||||
[SosRemoteDataSource] as SosDS
|
||||
[LocationRemoteDataSource] as LocDS
|
||||
[NotificationRemoteDataSource] as NotifDS
|
||||
[GuardianRemoteDataSource] as GrdDS
|
||||
}
|
||||
|
||||
package "Local Data Sources (Drift/SQLite)" as LOCAL_DS {
|
||||
[SecureStorageService] as SecStore
|
||||
[LocalDatabase (Drift)] as DriftDB
|
||||
[SharedPreferencesService] as SharedPref
|
||||
}
|
||||
|
||||
package "Core Services (Singletons)" as CORE_SVC {
|
||||
[TtsService\n(flutter_tts)] as TTS
|
||||
[SttService\n(speech_to_text)] as STT
|
||||
[VoiceCommandHandler] as VCH
|
||||
[HapticService\n(vibration)] as HAP
|
||||
[HardwareShortcutListener] as HSL
|
||||
[FcmService\n(firebase_messaging)] as FCMSvc
|
||||
[WebSocketService\n(stomp_dart_client)] as WSSvc
|
||||
[AgoraService\n(agora_rtc_engine)] as AgoraSvc
|
||||
}
|
||||
|
||||
package "AI Engine (On-Device)" as AI_ENGINE {
|
||||
[ModelLoader\n(tflite_flutter)] as ModelLoader
|
||||
[YoloDetector\n(YOLOv8n)] as YOLO
|
||||
[ObstacleAnalyzer] as ObsAnalyzer
|
||||
}
|
||||
|
||||
[ApiClient\n(Dio + Interceptors)] as DioClient
|
||||
}
|
||||
}
|
||||
|
||||
database "PostgreSQL" as DB
|
||||
cloud "Firebase FCM" as FCM
|
||||
cloud "Agora RTC" as Agora
|
||||
cloud "OpenStreetMap/OSRM" as Maps
|
||||
' ─────────────────────────────────────────────
|
||||
' BACKEND SERVER — Spring Boot
|
||||
' ─────────────────────────────────────────────
|
||||
package "Backend Server (Spring Boot @ 202.46.28.160:8080)" as BACKEND #E8F5E9 {
|
||||
|
||||
package "Security & Config" as SEC_CFG {
|
||||
[SecurityConfig\n(CORS, CSRF, RBAC)] as SecConfig
|
||||
[JwtAuthFilter\n(OncePerRequestFilter)] as JWTFilter
|
||||
[JwtUtil\n(JJWT)] as JWTUtil
|
||||
[CustomUserDetailsService] as CUDS
|
||||
[WebSocketConfig\n(STOMP)] as WSConfig
|
||||
[GlobalExceptionHandler\n(@ControllerAdvice)] as GEH
|
||||
[SwaggerConfig\n(Springdoc OpenAPI)] as SwagConfig
|
||||
}
|
||||
|
||||
package "Controller Layer" as CTRL {
|
||||
[AuthController\n/api/v1/auth] as AuthCtrl
|
||||
[PairingController\n/api/v1/shared/pairing] as PairCtrl
|
||||
[GuardianController\n/api/v1/guardian] as GrdCtrl
|
||||
[UserController\n/api/v1/user] as UserCtrl
|
||||
[CallController\n/api/v1/shared/call] as CallCtrl
|
||||
}
|
||||
|
||||
package "Service Layer" as SVC {
|
||||
[AuthService] as AuthSvc
|
||||
[PairingService] as PairSvc
|
||||
[ActivityLogService] as ActLogSvc
|
||||
[LocationService\n(+ Haversine)] as LocSvc
|
||||
[ObstacleLogService] as ObsLogSvc
|
||||
[NotificationService] as NotifSvc
|
||||
[SosService] as SosSvc
|
||||
[AiConfigService] as AICfgSvc
|
||||
[VoiceCommandService] as VCSvc
|
||||
[HardwareShortcutService] as HWSvc
|
||||
[GeofenceService\n(Haversine)] as GeoSvc
|
||||
[UserSettingsService] as UserSetSvc
|
||||
[FcmService\n(Firebase Admin)] as FcmBackSvc
|
||||
[AgoraTokenService] as AgoraSvcBE
|
||||
[GuardianDashboardService] as GDSvc
|
||||
}
|
||||
|
||||
package "WebSocket Broadcaster" as WS_BROADCAST {
|
||||
[LocationBroadcaster\n(SimpMessagingTemplate)] as LocBcast
|
||||
}
|
||||
|
||||
package "Repository Layer (Spring Data JPA)" as REPO {
|
||||
[UserRepository] as UserRepo
|
||||
[PairingRelationRepository] as PairRepo
|
||||
[ActivityLogRepository] as ActRepo
|
||||
[ObstacleLogRepository] as ObsRepo
|
||||
[LocationHistoryRepository] as LocRepo
|
||||
[GuardianNotificationRepository] as NotifRepo
|
||||
[SosEventRepository] as SosRepo
|
||||
[UserSettingsRepository] as UserSetRepo
|
||||
[AiConfigRepository] as AIRepo
|
||||
[VoiceCommandConfigRepository] as VCRepo
|
||||
[HardwareShortcutRepository] as HWRepo
|
||||
[GeofenceConfigRepository] as GeoRepo
|
||||
[RefreshTokenRepository] as RTRepo
|
||||
}
|
||||
|
||||
package "Domain / Entity" as ENTITY {
|
||||
[User] as UEnt
|
||||
[PairingRelation] as PRel
|
||||
[ActivityLog] as ActEnt
|
||||
[ObstacleLog] as ObsEnt
|
||||
[LocationHistory] as LocEnt
|
||||
[GuardianNotification] as NotifEnt
|
||||
[SosEvent] as SosEnt
|
||||
[UserSettings] as UserSetEnt
|
||||
[AiConfig] as AICfgEnt
|
||||
[VoiceCommandConfig] as VCEnt
|
||||
[HardwareShortcut] as HWEnt
|
||||
[GeofenceConfig] as GeoEnt
|
||||
[RefreshToken] as RTEnt
|
||||
}
|
||||
}
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' EXTERNAL SERVICES
|
||||
' ─────────────────────────────────────────────
|
||||
package "External Services" as EXTERNAL #FFF8E1 {
|
||||
cloud "Firebase (FCM)\nPush Notifications" as FCM
|
||||
cloud "Agora RTC\nVoIP Audio Call" as AGORA
|
||||
cloud "OpenStreetMap\n(flutter_map tiles)" as OSM
|
||||
}
|
||||
|
||||
database "PostgreSQL\n202.46.28.160:2002\n(University Server)\nFlyway Migrations V1-V16" as PGDB
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' PRESENTATION → BLOC
|
||||
' ─────────────────────────────────────────────
|
||||
SCS --> SCBloc
|
||||
SS --> AuthBloc
|
||||
LS --> AuthBloc
|
||||
RS --> AuthBloc
|
||||
WGS --> WGBloc
|
||||
WGS --> VCBloc
|
||||
SOSS --> SosBloc
|
||||
ALS --> ActLogBloc
|
||||
NS --> NotifBloc
|
||||
NMS --> LocBloc
|
||||
USS --> AuthBloc
|
||||
CS --> CallBloc
|
||||
UPS --> PairingBloc
|
||||
GDS --> GDBloc
|
||||
GMS --> LocBloc
|
||||
GALS --> ActLogBloc
|
||||
GSNS --> NotifBloc
|
||||
GACS --> AIRepoImpl
|
||||
GVCS --> VCBloc
|
||||
GSHS --> VCBloc
|
||||
GGFS --> GDBloc
|
||||
GPS --> PairingBloc
|
||||
ICS --> CallBloc
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' BLOC → USE CASES
|
||||
' ─────────────────────────────────────────────
|
||||
AuthBloc --> LoginUC
|
||||
AuthBloc --> RegUC
|
||||
AuthBloc --> LogoutUC
|
||||
PairingBloc --> InviteUC
|
||||
PairingBloc --> RespUC
|
||||
PairingBloc --> UnpairUC
|
||||
WGBloc --> StartUC
|
||||
WGBloc --> StopUC
|
||||
WGBloc --> LogObsUC
|
||||
SosBloc --> TrigSosUC
|
||||
LocBloc --> UpdLocUC
|
||||
CallBloc --> AgoraSvc
|
||||
VCBloc --> VCH
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' USE CASES → REPOSITORY INTERFACES
|
||||
' ─────────────────────────────────────────────
|
||||
LoginUC --> IAuthRepo
|
||||
RegUC --> IAuthRepo
|
||||
LogoutUC --> IAuthRepo
|
||||
InviteUC --> IPairRepo
|
||||
RespUC --> IPairRepo
|
||||
UnpairUC --> IPairRepo
|
||||
StartUC --> IWGRepo
|
||||
StopUC --> IWGRepo
|
||||
LogObsUC --> IWGRepo
|
||||
TrigSosUC --> ISosRepo
|
||||
UpdLocUC --> ILocRepo
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' INTERFACES → IMPLEMENTATIONS
|
||||
' ─────────────────────────────────────────────
|
||||
IAuthRepo <|.. AuthRepoImpl
|
||||
IPairRepo <|.. PairRepoImpl
|
||||
IWGRepo <|.. WGRepoImpl
|
||||
IActRepo <|.. ActRepoImpl
|
||||
ISosRepo <|.. SosRepoImpl
|
||||
ILocRepo <|.. LocRepoImpl
|
||||
INotifRepo <|.. NotifRepoImpl
|
||||
IAIRepo <|.. AIRepoImpl
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' REPO IMPL → DATA SOURCES
|
||||
' ─────────────────────────────────────────────
|
||||
AuthRepoImpl --> AuthDS
|
||||
AuthRepoImpl --> SecStore
|
||||
PairRepoImpl --> GrdDS
|
||||
WGRepoImpl --> WGDS
|
||||
WGRepoImpl --> DriftDB
|
||||
ActRepoImpl --> ActDS
|
||||
SosRepoImpl --> SosDS
|
||||
LocRepoImpl --> LocDS
|
||||
NotifRepoImpl --> NotifDS
|
||||
NotifRepoImpl --> DriftDB
|
||||
AIRepoImpl --> GrdDS
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' REMOTE DATA SOURCES → DioClient
|
||||
' ─────────────────────────────────────────────
|
||||
AuthDS --> DioClient
|
||||
WGDS --> DioClient
|
||||
ActDS --> DioClient
|
||||
SosDS --> DioClient
|
||||
LocDS --> DioClient
|
||||
NotifDS --> DioClient
|
||||
GrdDS --> DioClient
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' CORE SERVICES INTERCONNECT
|
||||
' ─────────────────────────────────────────────
|
||||
WGBloc --> YOLO
|
||||
WGBloc --> TTS
|
||||
WGBloc --> HAP
|
||||
YOLO --> ModelLoader
|
||||
YOLO --> ObsAnalyzer
|
||||
ObsAnalyzer --> TTS
|
||||
ObsAnalyzer --> HAP
|
||||
VCH --> STT
|
||||
VCH --> DriftDB
|
||||
HSL --> DriftDB
|
||||
FCMSvc --> DriftDB
|
||||
|
||||
SharedPref --> DioClient : baseUrl
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' DioClient → Backend REST API
|
||||
' ─────────────────────────────────────────────
|
||||
DioClient -down-> AuthCtrl : "POST /api/v1/auth/register\nPOST /api/v1/auth/login\nPOST /api/v1/auth/refresh\nPOST /api/v1/auth/logout\nPUT /api/v1/auth/fcm-token\nGET /api/v1/auth/ping"
|
||||
DioClient -down-> PairCtrl : "POST /shared/pairing/invite\nPOST /shared/pairing/respond\nDELETE /shared/pairing/unpair\nGET /shared/pairing/status"
|
||||
DioClient -down-> UserCtrl : "GET /user/profile\nPOST /user/location\nPOST /user/obstacle\nPOST /user/sos\nGET /user/notifications\nPOST /user/walkguide/start\nPOST /user/walkguide/stop"
|
||||
DioClient -down-> GrdCtrl : "GET /guardian/dashboard\nGET /guardian/user-location\nPOST /guardian/notifications/send\nPUT /guardian/ai-config\nPUT /guardian/geofence"
|
||||
DioClient -down-> CallCtrl : "POST /shared/call/token\nPOST /shared/call/notify"
|
||||
|
||||
' WebSocket STOMP
|
||||
WSSvc -down-> WSConfig : "ws://host:8080/ws\n(STOMP)\n/topic/location/{userId}\n/queue/sos/{guardianId}\n/queue/notif/{userId}"
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' BACKEND SECURITY FLOW
|
||||
' ─────────────────────────────────────────────
|
||||
JWTFilter --> JWTUtil : validate token
|
||||
JWTFilter --> CUDS : load user
|
||||
JWTFilter -up-> SecConfig : filter chain
|
||||
SecConfig -right-> JWTFilter
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' CONTROLLERS → SERVICES
|
||||
' ─────────────────────────────────────────────
|
||||
AuthCtrl --> AuthSvc
|
||||
PairCtrl --> PairSvc
|
||||
GrdCtrl --> GDSvc
|
||||
GrdCtrl --> LocSvc
|
||||
GrdCtrl --> ActLogSvc
|
||||
GrdCtrl --> ObsLogSvc
|
||||
GrdCtrl --> NotifSvc
|
||||
GrdCtrl --> SosSvc
|
||||
GrdCtrl --> AICfgSvc
|
||||
GrdCtrl --> VCSvc
|
||||
GrdCtrl --> HWSvc
|
||||
GrdCtrl --> GeoSvc
|
||||
UserCtrl --> AuthSvc
|
||||
UserCtrl --> LocSvc
|
||||
UserCtrl --> ObsLogSvc
|
||||
UserCtrl --> SosSvc
|
||||
UserCtrl --> ActLogSvc
|
||||
UserCtrl --> NotifSvc
|
||||
UserCtrl --> UserSetSvc
|
||||
UserCtrl --> VCSvc
|
||||
UserCtrl --> HWSvc
|
||||
UserCtrl --> AICfgSvc
|
||||
CallCtrl --> AgoraSvcBE
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' SERVICES CROSS-DEPENDENCIES
|
||||
' ─────────────────────────────────────────────
|
||||
AuthSvc --> PairSvc : seed defaults on register
|
||||
PairSvc --> VCSvc : seedDefaults()
|
||||
PairSvc --> HWSvc : seedDefaults()
|
||||
PairSvc --> AICfgSvc : create config on pair
|
||||
PairSvc --> FcmBackSvc : send FCM invite/response
|
||||
NotifSvc --> FcmBackSvc : send FCM notification
|
||||
SosSvc --> FcmBackSvc : send FCM SOS high-priority
|
||||
AICfgSvc --> FcmBackSvc : notify settings updated
|
||||
LocSvc --> GeoSvc : checkGeofence()
|
||||
LocSvc --> LocBcast : broadcastLocation()
|
||||
LocSvc --> ActLogSvc : log LOCATION_UPDATE
|
||||
NotifSvc --> LocBcast : broadcastNotification()
|
||||
SosSvc --> LocBcast : broadcastSos()
|
||||
SosSvc --> ActLogSvc : log SOS_TRIGGERED
|
||||
ActLogSvc --> LocBcast : broadcast to Guardian
|
||||
GDSvc --> LocSvc
|
||||
GDSvc --> ActLogSvc
|
||||
GDSvc --> NotifSvc
|
||||
GDSvc --> SosSvc
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' SERVICES → REPOSITORIES
|
||||
' ─────────────────────────────────────────────
|
||||
AuthSvc --> UserRepo
|
||||
AuthSvc --> RTRepo
|
||||
AuthSvc --> ActLogSvc
|
||||
PairSvc --> PairRepo
|
||||
PairSvc --> UserRepo
|
||||
ActLogSvc --> ActRepo
|
||||
LocSvc --> LocRepo
|
||||
LocSvc --> PairRepo
|
||||
ObsLogSvc --> ObsRepo
|
||||
ObsLogSvc --> ActLogSvc
|
||||
NotifSvc --> NotifRepo
|
||||
NotifSvc --> PairRepo
|
||||
SosSvc --> SosRepo
|
||||
SosSvc --> PairRepo
|
||||
AICfgSvc --> AIRepo
|
||||
VCSvc --> VCRepo
|
||||
HWSvc --> HWRepo
|
||||
GeoSvc --> GeoRepo
|
||||
UserSetSvc --> UserSetRepo
|
||||
FcmBackSvc --> UserRepo
|
||||
AgoraSvcBE --> UserRepo
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' REPOSITORIES → ENTITIES (JPA/Hibernate)
|
||||
' ─────────────────────────────────────────────
|
||||
UserRepo --> UEnt
|
||||
PairRepo --> PRel
|
||||
ActRepo --> ActEnt
|
||||
ObsRepo --> ObsEnt
|
||||
LocRepo --> LocEnt
|
||||
NotifRepo --> NotifEnt
|
||||
SosRepo --> SosEnt
|
||||
UserSetRepo --> UserSetEnt
|
||||
AIRepo --> AICfgEnt
|
||||
VCRepo --> VCEnt
|
||||
HWRepo --> HWEnt
|
||||
GeoRepo --> GeoEnt
|
||||
RTRepo --> RTEnt
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' ENTITIES → DATABASE
|
||||
' ─────────────────────────────────────────────
|
||||
UEnt --> PGDB : JPA/Hibernate
|
||||
PRel --> PGDB
|
||||
ActEnt --> PGDB
|
||||
ObsEnt --> PGDB
|
||||
LocEnt --> PGDB
|
||||
NotifEnt --> PGDB
|
||||
SosEnt --> PGDB
|
||||
UserSetEnt --> PGDB
|
||||
AICfgEnt --> PGDB
|
||||
VCEnt --> PGDB
|
||||
HWEnt --> PGDB
|
||||
GeoEnt --> PGDB
|
||||
RTEnt --> PGDB
|
||||
|
||||
' ─────────────────────────────────────────────
|
||||
' EXTERNAL SERVICES
|
||||
' ─────────────────────────────────────────────
|
||||
FcmBackSvc -right-> FCM : "Firebase Admin SDK\nFirebaseMessaging.send()"
|
||||
AgoraSvcBE -right-> AGORA : "Generate RTC Token\n(server-side Agora SDK)"
|
||||
AgoraSvc --> AGORA : "agora_rtc_engine\nJoin Channel (audio-only)"
|
||||
FCMSvc --> FCM : "firebase_messaging\nReceive push"
|
||||
GMS --> OSM : "flutter_map\nOpenStreetMap tiles"
|
||||
|
||||
note top of PGDB
|
||||
Tables (Flyway V1–V16):
|
||||
users, pairing_relations,
|
||||
activity_logs, obstacle_logs,
|
||||
location_history, guardian_notifications,
|
||||
sos_events, user_settings, ai_configs,
|
||||
voice_command_configs, hardware_shortcuts,
|
||||
geofence_configs, refresh_tokens
|
||||
end note
|
||||
|
||||
note right of AI_ENGINE
|
||||
On-Device AI (No server call):
|
||||
YOLOv8n .tflite (640×640)
|
||||
→ detect obstacles
|
||||
→ direction: LEFT/CENTER/RIGHT
|
||||
→ TTS + Haptic alert
|
||||
NNAPI delegate (Android AI accel.)
|
||||
end note
|
||||
|
||||
note left of SharedPref
|
||||
Dynamic Server URL:
|
||||
User inputs "http://202.46.28.160:8080"
|
||||
Saved to SharedPreferences
|
||||
No hardcoded BASE_URL in APK
|
||||
end note
|
||||
|
||||
note bottom of WSSvc
|
||||
WebSocket STOMP:
|
||||
/topic/location/{userId} → Guardian map real-time
|
||||
/queue/sos/{guardianId} → SOS alert instant
|
||||
/queue/notif/{userId} → Notification push
|
||||
end note
|
||||
|
||||
[Presentation Screens] --> [ApiClient + Interceptors]
|
||||
[ApiClient + Interceptors] --> [Controllers] : REST /api/v1
|
||||
[WebSocketService] --> [WebSocket Broker] : STOMP /ws
|
||||
[CallService] --> [Controllers] : /shared/call
|
||||
[Controllers] --> [Services]
|
||||
[Services] --> [Repositories]
|
||||
[Repositories] --> DB
|
||||
[Services] --> FCM
|
||||
[CallService] --> Agora
|
||||
[Presentation Screens] --> Maps
|
||||
@enduml
|
||||
|
||||
381
ooad-docs/diagrams/sequence-diagram.puml
Normal file
381
ooad-docs/diagrams/sequence-diagram.puml
Normal 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
|
||||
470
ooad-docs/diagrams/state-machine.puml
Normal file
470
ooad-docs/diagrams/state-machine.puml
Normal 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
|
||||
176
ooad-docs/diagrams/usecase-diagram.puml
Normal file
176
ooad-docs/diagrams/usecase-diagram.puml
Normal 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
1471
report/main.tex
Normal file
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
35
walkguide-backend/demo/src/main/resources/.env.example
Normal file
35
walkguide-backend/demo/src/main/resources/.env.example
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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() {
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
6
walkguide-mobile/walkguide_app/.gitignore
vendored
6
walkguide-mobile/walkguide_app/.gitignore
vendored
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
1
walkguide-mobile/walkguide_app/dart_test.yaml
Normal file
1
walkguide-mobile/walkguide_app/dart_test.yaml
Normal file
@ -0,0 +1 @@
|
||||
concurrency: 1
|
||||
@ -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)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: '');
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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 ----------
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 ----------
|
||||
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
@ -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')))
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user