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 ###
|
### VS Code ###
|
||||||
.vscode/
|
.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
|
skinparam componentStyle rectangle
|
||||||
|
skinparam backgroundColor #FAFAFA
|
||||||
component "Flutter Mobile App" {
|
skinparam component {
|
||||||
[Presentation Screens]
|
BackgroundColor #EEF2FF
|
||||||
[ApiClient + Interceptors]
|
BorderColor #6366F1
|
||||||
[TTS/STT/YOLO/Location Services]
|
FontColor #1E1B4B
|
||||||
[WebSocketService]
|
FontSize 11
|
||||||
[CallService]
|
}
|
||||||
|
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]
|
' MOBILE DEVICE — Flutter App
|
||||||
[Services]
|
' ─────────────────────────────────────────────
|
||||||
[Repositories]
|
package "Mobile Device (Flutter App)" as FLUTTER #E8EAF6 {
|
||||||
[Security/JWT]
|
|
||||||
[WebSocket Broker]
|
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
|
' BACKEND SERVER — Spring Boot
|
||||||
cloud "Agora RTC" as Agora
|
' ─────────────────────────────────────────────
|
||||||
cloud "OpenStreetMap/OSRM" as Maps
|
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
|
@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>
|
<plugins>
|
||||||
|
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
<configuration>
|
<configuration>
|
||||||
<annotationProcessorPaths>
|
<annotationProcessorPaths>
|
||||||
<path>
|
<path>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<version>1.18.36</version>
|
<version>1.18.36</version>
|
||||||
</path>
|
</path>
|
||||||
</annotationProcessorPaths>
|
</annotationProcessorPaths>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
<executions>
|
||||||
|
<execution>
|
||||||
<plugin>
|
<id>default-compile</id>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<phase>compile</phase>
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
<goals><goal>compile</goal></goals>
|
||||||
<executions>
|
</execution>
|
||||||
<execution>
|
<execution>
|
||||||
<id>default-compile</id>
|
<id>default-testCompile</id>
|
||||||
<phase>compile</phase>
|
<phase>test-compile</phase>
|
||||||
<goals><goal>compile</goal></goals>
|
<goals><goal>testCompile</goal></goals>
|
||||||
<configuration>
|
</execution>
|
||||||
<annotationProcessorPaths>
|
</executions>
|
||||||
<path>
|
</plugin>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- JACOCO - Code Coverage >=70% -->
|
<!-- JACOCO - Code Coverage >=70% -->
|
||||||
<plugin>
|
<plugin>
|
||||||
|
|||||||
@ -114,6 +114,16 @@ public class UserController {
|
|||||||
"SOS dikirim! Guardian sudah diberitahu."));
|
"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")
|
@GetMapping("/activity-logs")
|
||||||
public ResponseEntity<ApiResponse<Page<ActivityLogResponse>>> getActivityLogs(
|
public ResponseEntity<ApiResponse<Page<ActivityLogResponse>>> getActivityLogs(
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@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 =====
|
||||||
server.port=8080
|
server.port=${SERVER_PORT:8080}
|
||||||
|
|
||||||
# ===== POSTGRESQL CONNECTION =====
|
# ===== POSTGRESQL CONNECTION =====
|
||||||
spring.datasource.url=jdbc:postgresql://202.46.28.160:2002/uas_5803024001
|
spring.datasource.url=${DB_URL}
|
||||||
spring.datasource.username=5803024001
|
spring.datasource.username=${DB_USERNAME}
|
||||||
spring.datasource.password=pw5803024001
|
spring.datasource.password=${DB_PASSWORD}
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
|
|
||||||
# ===== JPA / HIBERNATE =====
|
# ===== JPA / HIBERNATE =====
|
||||||
@ -19,24 +19,23 @@ spring.flyway.locations=classpath:db/migration
|
|||||||
spring.flyway.baseline-on-migrate=true
|
spring.flyway.baseline-on-migrate=true
|
||||||
|
|
||||||
# ===== JWT =====
|
# ===== JWT =====
|
||||||
jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
|
jwt.secret=${JWT_SECRET}
|
||||||
jwt.expiration=86400000
|
jwt.expiration=${JWT_EXPIRATION:86400000}
|
||||||
|
|
||||||
# ===== SWAGGER =====
|
# ===== SWAGGER =====
|
||||||
springdoc.swagger-ui.path=/swagger-ui.html
|
springdoc.swagger-ui.path=/swagger-ui.html
|
||||||
springdoc.api-docs.path=/v3/api-docs
|
springdoc.api-docs.path=/v3/api-docs
|
||||||
|
|
||||||
# ===== AGORA RTC =====
|
# ===== AGORA RTC =====
|
||||||
# Isi dengan nilai dari dashboard.agora.io setelah buat project
|
agora.app-id=${AGORA_APP_ID:}
|
||||||
# Jika kosong: AgoraTokenService akan generate token kosong (mode demo/testing)
|
agora.app-certificate=${AGORA_APP_CERTIFICATE:}
|
||||||
agora.app-id=
|
|
||||||
agora.app-certificate=
|
|
||||||
|
|
||||||
# ===== WEBSOCKET =====
|
# ===== WEBSOCKET =====
|
||||||
# WebSocket auto-dikonfigurasi oleh WebSocketConfig.java
|
# WebSocket auto-dikonfigurasi oleh WebSocketConfig.java
|
||||||
# Tidak perlu config tambahan — Spring Boot auto-detect starter-websocket
|
|
||||||
|
|
||||||
# ===== LOGGING =====
|
# ===== LOGGING =====
|
||||||
logging.level.com.walkguide=DEBUG
|
logging.level.com.walkguide=DEBUG
|
||||||
logging.level.org.springframework.messaging=INFO
|
logging.level.org.springframework.messaging=INFO
|
||||||
logging.level.org.springframework.web.socket=INFO
|
logging.level.org.springframework.web.socket=INFO
|
||||||
|
|
||||||
|
spring.profiles.active=dev
|
||||||
@ -183,6 +183,10 @@ paths:
|
|||||||
post:
|
post:
|
||||||
responses:
|
responses:
|
||||||
"200": { description: SOS triggered }
|
"200": { description: SOS triggered }
|
||||||
|
/user/sos-events:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
"200": { description: User SOS history }
|
||||||
/user/activity-logs:
|
/user/activity-logs:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
@ -1,11 +1,23 @@
|
|||||||
package com.walkguide;
|
package com.walkguide;
|
||||||
|
|
||||||
|
import com.walkguide.config.DataSeeder;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
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 {
|
class DemoApplicationTests {
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private DataSeeder dataSeeder;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contextLoads() {
|
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 =====
|
// ===== ACTIVITY LOGS =====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -7,9 +7,11 @@ import com.walkguide.entity.RefreshToken;
|
|||||||
import com.walkguide.entity.User;
|
import com.walkguide.entity.User;
|
||||||
import com.walkguide.entity.UserSettings;
|
import com.walkguide.entity.UserSettings;
|
||||||
import com.walkguide.enums.ActivityLogType;
|
import com.walkguide.enums.ActivityLogType;
|
||||||
|
import com.walkguide.repository.HardwareShortcutRepository;
|
||||||
import com.walkguide.repository.RefreshTokenRepository;
|
import com.walkguide.repository.RefreshTokenRepository;
|
||||||
import com.walkguide.repository.UserRepository;
|
import com.walkguide.repository.UserRepository;
|
||||||
import com.walkguide.repository.UserSettingsRepository;
|
import com.walkguide.repository.UserSettingsRepository;
|
||||||
|
import com.walkguide.repository.VoiceCommandConfigRepository;
|
||||||
import com.walkguide.security.JwtUtil;
|
import com.walkguide.security.JwtUtil;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
@ -35,6 +37,8 @@ class AuthServiceTest {
|
|||||||
@Mock UserRepository userRepository;
|
@Mock UserRepository userRepository;
|
||||||
@Mock RefreshTokenRepository refreshTokenRepository;
|
@Mock RefreshTokenRepository refreshTokenRepository;
|
||||||
@Mock UserSettingsRepository userSettingsRepository;
|
@Mock UserSettingsRepository userSettingsRepository;
|
||||||
|
@Mock HardwareShortcutRepository hardwareShortcutRepository;
|
||||||
|
@Mock VoiceCommandConfigRepository voiceCommandConfigRepository;
|
||||||
@Mock ActivityLogService activityLogService;
|
@Mock ActivityLogService activityLogService;
|
||||||
@Mock JwtUtil jwtUtil;
|
@Mock JwtUtil jwtUtil;
|
||||||
@Mock PasswordEncoder passwordEncoder;
|
@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/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/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 {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
@ -24,7 +25,7 @@ android {
|
|||||||
applicationId = "com.example.walkguide_app"
|
applicationId = "com.example.walkguide_app"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = 26
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
@ -42,3 +43,7 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
|
}
|
||||||
|
|||||||
@ -15,6 +15,42 @@ subprojects {
|
|||||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
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 {
|
subprojects {
|
||||||
project.evaluationDependsOn(":app")
|
project.evaluationDependsOn(":app")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=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 }
|
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 {
|
class DetectionResult {
|
||||||
final String label;
|
final String label;
|
||||||
final double confidence;
|
final double confidence;
|
||||||
@ -26,12 +44,73 @@ class DetectionResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ObstacleAnalyzer {
|
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(
|
return DetectionResult(
|
||||||
label: label,
|
label: label,
|
||||||
confidence: confidence,
|
confidence: confidence,
|
||||||
direction: ObstacleDirection.center,
|
direction: ObstacleDirection.center,
|
||||||
estimatedDistance: 'Close',
|
estimatedDistance: 'Close (1-2m)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,7 @@ class AppConstants {
|
|||||||
await prefs.setString(_selectedYoloModelKey, path);
|
await prefs.setString(_selectedYoloModelKey, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agora - ganti dengan App ID dari agora.io
|
// Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=...
|
||||||
static const String agoraAppId = 'YOUR_AGORA_APP_ID';
|
static const String agoraAppId =
|
||||||
|
String.fromEnvironment('AGORA_APP_ID', defaultValue: '');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ class CallService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int?> getPairedReceiverId() async {
|
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'];
|
final data = res.data['data'];
|
||||||
if (data is! Map<String, dynamic>) return null;
|
if (data is! Map<String, dynamic>) return null;
|
||||||
final id = data['pairedWithId'];
|
final id = data['pairedWithId'];
|
||||||
@ -72,6 +72,10 @@ class CallService {
|
|||||||
int uid = 0,
|
int uid = 0,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
|
if (AppConstants.agoraAppId.isEmpty) {
|
||||||
|
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
_engine ??= createAgoraRtcEngine();
|
_engine ??= createAgoraRtcEngine();
|
||||||
await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId));
|
await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId));
|
||||||
await _engine!.enableAudio();
|
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'
|
export '../screens.dart'
|
||||||
show
|
show
|
||||||
GuardianDashboardScreen,
|
|
||||||
GuardianMapScreen,
|
GuardianMapScreen,
|
||||||
GuardianActivityLogScreen,
|
|
||||||
GuardianSendNotifScreen,
|
GuardianSendNotifScreen,
|
||||||
GuardianAiConfigScreen,
|
|
||||||
GuardianVoiceCmdScreen,
|
GuardianVoiceCmdScreen,
|
||||||
GuardianShortcutScreen,
|
GuardianShortcutScreen,
|
||||||
GuardianGeofenceScreen;
|
GuardianGeofenceScreen;
|
||||||
|
|||||||
@ -28,6 +28,8 @@ import '../core/services/tts_service.dart';
|
|||||||
import '../core/services/websocket_service.dart';
|
import '../core/services/websocket_service.dart';
|
||||||
import '../core/storage/secure_storage.dart';
|
import '../core/storage/secure_storage.dart';
|
||||||
|
|
||||||
|
export 'guardian_dashboard/guardian_screens.dart';
|
||||||
|
|
||||||
Dio get _api => sl<ApiClient>().dio;
|
Dio get _api => sl<ApiClient>().dio;
|
||||||
|
|
||||||
class ServerConnectScreen extends StatefulWidget {
|
class ServerConnectScreen extends StatefulWidget {
|
||||||
@ -779,26 +781,12 @@ class IncomingCallScreen extends StatelessWidget {
|
|||||||
text: 'Accept or reject incoming guardian calls here.');
|
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 {
|
class GuardianMapScreen extends StatelessWidget {
|
||||||
const GuardianMapScreen({super.key});
|
const GuardianMapScreen({super.key});
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => const _GuardianMapHistoryScreen();
|
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 {
|
class GuardianSendNotifScreen extends StatefulWidget {
|
||||||
const GuardianSendNotifScreen({super.key});
|
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 {
|
class GuardianVoiceCmdScreen extends StatelessWidget {
|
||||||
const GuardianVoiceCmdScreen({super.key});
|
const GuardianVoiceCmdScreen({super.key});
|
||||||
@override
|
@override
|
||||||
@ -977,7 +948,7 @@ class _GuardianMapHistoryScreen extends StatelessWidget {
|
|||||||
child: _MapScreenBody(guardianEndpoint: '/guardian/user-location'),
|
child: _MapScreenBody(guardianEndpoint: '/guardian/user-location'),
|
||||||
),
|
),
|
||||||
SizedBox(height: 12),
|
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) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
constraints: const BoxConstraints(minHeight: 180),
|
constraints: const BoxConstraints(minHeight: 0),
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: const Color(0xFFF8FAFC),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@ -2196,15 +2167,17 @@ class _JsonCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Expanded(
|
||||||
width: double.infinity,
|
child: Container(
|
||||||
constraints: const BoxConstraints(minHeight: 220),
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: const Color(0xFFE2E8F0))),
|
border: Border.all(color: const Color(0xFFE2E8F0))),
|
||||||
child: SingleChildScrollView(child: Text(data?.toString() ?? 'No data')),
|
child:
|
||||||
|
SingleChildScrollView(child: Text(data?.toString() ?? 'No data')),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -947,10 +947,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mgrs_dart:
|
mgrs_dart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1264,13 +1264,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.2"
|
version: "1.2.2"
|
||||||
record_linux:
|
record_linux:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: record_linux
|
name: record_linux
|
||||||
sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3"
|
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "1.3.0"
|
||||||
record_platform_interface:
|
record_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1592,26 +1592,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.26.2"
|
version: "1.26.3"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.7"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
|
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.11"
|
version: "0.6.12"
|
||||||
tflite_flutter:
|
tflite_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -91,6 +91,9 @@ dev_dependencies:
|
|||||||
mockito: ^5.4.4
|
mockito: ^5.4.4
|
||||||
bloc_test: ^9.1.7
|
bloc_test: ^9.1.7
|
||||||
|
|
||||||
|
dependency_overrides:
|
||||||
|
record_linux: ^1.3.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
|
|||||||
@ -127,11 +127,10 @@ class _AppState extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendSos() async {
|
void sendSos() {
|
||||||
await Future.delayed(const Duration(milliseconds: 200));
|
_sosSent = true;
|
||||||
_sosSent = true;
|
notifyListeners();
|
||||||
notifyListeners();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
void markAllRead() {
|
void markAllRead() {
|
||||||
_notifications = _notifications
|
_notifications = _notifications
|
||||||
|
|||||||
@ -60,14 +60,11 @@ class _AppState extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startWalkGuide() async {
|
Future<void> startWalkGuide() async {
|
||||||
_walkGuideStatus = _WalkGuideStatus.active;
|
_walkGuideStatus = _WalkGuideStatus.active;
|
||||||
notifyListeners();
|
_detectedObstacles = ['person (87%)', 'motorcycle (72%)'];
|
||||||
// Simulasi obstacle terdeteksi setelah 300ms
|
notifyListeners();
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
}
|
||||||
_detectedObstacles = ['person (87%)', 'motorcycle (72%)'];
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void stopWalkGuide() {
|
void stopWalkGuide() {
|
||||||
_walkGuideStatus = _WalkGuideStatus.idle;
|
_walkGuideStatus = _WalkGuideStatus.idle;
|
||||||
@ -80,11 +77,10 @@ class _AppState extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendSos() async {
|
Future<void> sendSos() async {
|
||||||
await Future.delayed(const Duration(milliseconds: 150));
|
_sosStatus = _SosStatus.triggered;
|
||||||
_sosStatus = _SosStatus.triggered;
|
notifyListeners();
|
||||||
notifyListeners();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
void goBack() {
|
void goBack() {
|
||||||
if (_currentScreen == 'walkguide' || _currentScreen == 'sos') {
|
if (_currentScreen == 'walkguide' || _currentScreen == 'sos') {
|
||||||
|
|||||||
@ -101,11 +101,10 @@ class _AppState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> markAllAsRead() async {
|
Future<void> markAllAsRead() async {
|
||||||
await Future.delayed(const Duration(milliseconds: 150));
|
for (final n in _notifications) {
|
||||||
for (final n in _notifications) {
|
n.isRead = true;
|
||||||
n.isRead = true;
|
}
|
||||||
}
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -221,39 +220,42 @@ class _DashboardScreen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final unread = state.unreadCount;
|
final unread = state.unreadCount;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Dashboard'),
|
title: const Text('Dashboard'),
|
||||||
actions: [
|
actions: [
|
||||||
Stack(
|
IconButton(
|
||||||
alignment: Alignment.topRight,
|
key: const Key('notifIconButton'),
|
||||||
children: [
|
icon: Stack(
|
||||||
IconButton(
|
clipBehavior: Clip.none,
|
||||||
key: const Key('notifIconButton'),
|
children: [
|
||||||
icon: const Icon(Icons.notifications),
|
const Icon(Icons.notifications),
|
||||||
tooltip: 'Notifikasi',
|
if (unread > 0)
|
||||||
onPressed: state.openNotifications,
|
Positioned(
|
||||||
),
|
right: -4,
|
||||||
if (unread > 0)
|
top: -4,
|
||||||
Positioned(
|
child: IgnorePointer(
|
||||||
right: 8,
|
child: Container(
|
||||||
top: 8,
|
key: const Key('dashboardBadge'),
|
||||||
child: Container(
|
padding: const EdgeInsets.all(4),
|
||||||
key: const Key('dashboardBadge'),
|
decoration: const BoxDecoration(
|
||||||
padding: const EdgeInsets.all(4),
|
color: Colors.red,
|
||||||
decoration: const BoxDecoration(
|
shape: BoxShape.circle,
|
||||||
color: Colors.red,
|
),
|
||||||
shape: BoxShape.circle,
|
child: Text(
|
||||||
),
|
'$unread',
|
||||||
child: Text(
|
style: const TextStyle(
|
||||||
'$unread',
|
color: Colors.white, fontSize: 10),
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
tooltip: 'Notifikasi',
|
||||||
),
|
onPressed: state.openNotifications,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
const Text('Selamat datang di Dashboard'),
|
const Text('Selamat datang di Dashboard'),
|
||||||
|
|||||||
@ -47,7 +47,19 @@ abstract class AuthRepository {
|
|||||||
// File mock di-generate via: flutter pub run build_runner build
|
// File mock di-generate via: flutter pub run build_runner build
|
||||||
// Untuk demo tanpa build_runner, kita buat manual mock di bawah
|
// 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 ----------
|
// ---------- Use case ----------
|
||||||
|
|
||||||
|
|||||||
@ -98,19 +98,20 @@ class ObstacleAnalyzer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Pilih obstacle paling prioritas (Very Close > Close > Medium > Far).
|
/// Pilih obstacle paling prioritas (Very Close > Close > Medium > Far).
|
||||||
DetectionResult? prioritize(List<DetectionResult> detections) {
|
DetectionResult? prioritize(List<DetectionResult> detections) {
|
||||||
if (detections.isEmpty) return null;
|
if (detections.isEmpty) return null;
|
||||||
const order = [
|
const order = [
|
||||||
'Very Close (< 1m)',
|
'Very Close (< 1m)',
|
||||||
'Close (1-2m)',
|
'Close (1-2m)',
|
||||||
'Medium (2-4m)',
|
'Medium (2-4m)',
|
||||||
'Far (> 4m)',
|
'Far (> 4m)',
|
||||||
];
|
];
|
||||||
detections.sort((a, b) => order
|
final sorted = List<DetectionResult>.of(detections);
|
||||||
.indexOf(a.estimatedDistance)
|
sorted.sort((a, b) => order
|
||||||
.compareTo(order.indexOf(b.estimatedDistance)));
|
.indexOf(a.estimatedDistance)
|
||||||
return detections.first;
|
.compareTo(order.indexOf(b.estimatedDistance)));
|
||||||
}
|
return sorted.first;
|
||||||
|
}
|
||||||
|
|
||||||
/// Filter deteksi berdasarkan confidence threshold.
|
/// Filter deteksi berdasarkan confidence threshold.
|
||||||
List<DetectionResult> filterByConfidence(
|
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 ----------
|
// ---------- Use case ----------
|
||||||
|
|
||||||
|
|||||||
@ -261,13 +261,13 @@ void main() {
|
|||||||
testWidgets('password tersembunyi (obscureText=true) secara default', (tester) async {
|
testWidgets('password tersembunyi (obscureText=true) secara default', (tester) async {
|
||||||
await tester.pumpWidget(makeTestable(const _StubLoginScreen()));
|
await tester.pumpWidget(makeTestable(const _StubLoginScreen()));
|
||||||
|
|
||||||
final editableText = tester.widget<EditableText>(
|
final editableText = tester.widget<EditableText>(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('password_field')),
|
of: find.byKey(const Key('password_field')),
|
||||||
matching: find.byType(EditableText),
|
matching: find.byType(EditableText),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(editableText.obscureText, isTrue);
|
expect(editableText.obscureText, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tap toggle mengubah obscureText menjadi false', (tester) async {
|
testWidgets('tap toggle mengubah obscureText menjadi false', (tester) async {
|
||||||
@ -282,7 +282,7 @@ void main() {
|
|||||||
matching: find.byType(EditableText),
|
matching: find.byType(EditableText),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(editableText.obscureText, isTrue);
|
expect(editableText.obscureText, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('tap toggle dua kali mengembalikan obscureText ke true', (tester) async {
|
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.enterText(find.byKey(const Key('password_field')), 'password123');
|
||||||
await tester.tap(find.byKey(const Key('login_button')));
|
await tester.tap(find.byKey(const Key('login_button')));
|
||||||
await tester.pump(); // Trigger rebuild
|
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 {
|
testWidgets('menyembunyikan tombol login saat loading', (tester) async {
|
||||||
await tester.pumpWidget(makeTestable(const _StubLoginScreen()));
|
await tester.pumpWidget(makeTestable(const _StubLoginScreen()));
|
||||||
@ -324,9 +325,10 @@ void main() {
|
|||||||
await tester.enterText(find.byKey(const Key('password_field')), 'password123');
|
await tester.enterText(find.byKey(const Key('password_field')), 'password123');
|
||||||
await tester.tap(find.byKey(const Key('login_button')));
|
await tester.tap(find.byKey(const Key('login_button')));
|
||||||
await tester.pump();
|
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 {
|
testWidgets('loading selesai setelah async operation', (tester) async {
|
||||||
await tester.pumpWidget(makeTestable(const _StubLoginScreen()));
|
await tester.pumpWidget(makeTestable(const _StubLoginScreen()));
|
||||||
|
|||||||
@ -363,11 +363,16 @@ class _StubManualScreenState extends State<_StubManualScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget makeTestable(Widget child) => MaterialApp(home: child);
|
Widget makeTestable(Widget child) => MaterialApp(home: child);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
Finder _commandScrollable() => find.descendant(
|
||||||
// Tests
|
of: find.byKey(const Key('command_list')),
|
||||||
// ---------------------------------------------------------------------------
|
matching: find.byType(Scrollable),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('ManualScreen Widget Tests', () {
|
group('ManualScreen Widget Tests', () {
|
||||||
@ -418,12 +423,13 @@ void main() {
|
|||||||
group('Konten perintah suara', () {
|
group('Konten perintah suara', () {
|
||||||
testWidgets('menampilkan tile untuk perintah Open Walkguide',
|
testWidgets('menampilkan tile untuk perintah Open Walkguide',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
|
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
|
||||||
await tester.scrollUntilVisible(
|
await tester.scrollUntilVisible(
|
||||||
find.byKey(const Key('cmd_tile_openWalkguide')),
|
find.byKey(const Key('cmd_tile_openWalkguide')),
|
||||||
200,
|
200,
|
||||||
);
|
scrollable: _commandScrollable(),
|
||||||
expect(find.byKey(const Key('cmd_tile_openWalkguide')), findsOneWidget);
|
);
|
||||||
|
expect(find.byKey(const Key('cmd_tile_openWalkguide')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('menampilkan phrase perintah dalam tanda kutip',
|
testWidgets('menampilkan phrase perintah dalam tanda kutip',
|
||||||
@ -449,25 +455,36 @@ void main() {
|
|||||||
expect(find.text('"Call Guardian"'), findsOneWidget);
|
expect(find.text('"Call Guardian"'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('menampilkan perintah Send SOS', (tester) async {
|
testWidgets('menampilkan perintah Send SOS', (tester) async {
|
||||||
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
|
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
|
||||||
await tester.scrollUntilVisible(find.text('"Send SOS"'), 200);
|
await tester.scrollUntilVisible(
|
||||||
expect(find.text('"Send SOS"'), findsOneWidget);
|
find.text('"Send SOS"'),
|
||||||
});
|
200,
|
||||||
|
scrollable: _commandScrollable(),
|
||||||
testWidgets('menampilkan perintah Where Am I', (tester) async {
|
);
|
||||||
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
|
expect(find.text('"Send SOS"'), findsOneWidget);
|
||||||
await tester.scrollUntilVisible(find.text('"Where Am I"'), 200);
|
});
|
||||||
expect(find.text('"Where Am I"'), 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',
|
testWidgets('menampilkan kategori Darurat untuk Send SOS',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
|
await tester.pumpWidget(makeTestable(const _StubManualScreen()));
|
||||||
await tester.scrollUntilVisible(
|
await tester.scrollUntilVisible(
|
||||||
find.byKey(const Key('cmd_category_sendSos')), 200);
|
find.byKey(const Key('cmd_category_sendSos')),
|
||||||
expect(find.byKey(const Key('cmd_category_sendSos')), findsOneWidget);
|
200,
|
||||||
});
|
scrollable: _commandScrollable(),
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('cmd_category_sendSos')), findsOneWidget);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Dialog info ───────────────────────────────────────────────────────
|
// ── Dialog info ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -103,15 +103,16 @@ class _StubNotificationScreenState extends State<_StubNotificationScreen> {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (_items.any((e) => !e.isRead))
|
if (_items.any((e) => !e.isRead))
|
||||||
TextButton(
|
IconButton(
|
||||||
key: const Key('mark_all_read_button'),
|
key: const Key('mark_all_read_button'),
|
||||||
onPressed: _markingAll ? null : _markAllRead,
|
onPressed: _markingAll ? null : _markAllRead,
|
||||||
child: const Text('Tandai Semua Dibaca'),
|
tooltip: 'Tandai Semua Dibaca',
|
||||||
),
|
icon: const Icon(Icons.done_all),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
body: widget.isLoading
|
body: widget.isLoading
|
||||||
? const Center(
|
? const Center(
|
||||||
child: CircularProgressIndicator(key: Key('loading_indicator')))
|
child: CircularProgressIndicator(key: Key('loading_indicator')))
|
||||||
|
|||||||
@ -431,9 +431,10 @@ void main() {
|
|||||||
|
|
||||||
await tester.tap(find.byKey(const Key('sos_button')));
|
await tester.tap(find.byKey(const Key('sos_button')));
|
||||||
await tester.pump();
|
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',
|
testWidgets('setelah SOS terkirim, tampil success banner',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user