docs: add design patterns documentation for WalkGuide
This commit is contained in:
parent
697d011d23
commit
c862d4eaeb
499
ooad-docs/DESIGN_PATTERNS.puml
Normal file
499
ooad-docs/DESIGN_PATTERNS.puml
Normal file
@ -0,0 +1,499 @@
|
||||
' ============================================================
|
||||
' WALKGUIDE — DESIGN PATTERNS (GoF)
|
||||
' Flutter × Spring Boot × In-Device AI
|
||||
' 7 Patterns: 2 Creational · 2 Structural · 3 Behavioral
|
||||
' ============================================================
|
||||
|
||||
@startuml WalkGuide_Design_Patterns
|
||||
|
||||
skinparam monochrome false
|
||||
skinparam shadowing false
|
||||
skinparam defaultFontName Arial
|
||||
skinparam defaultFontSize 12
|
||||
skinparam roundCorner 10
|
||||
skinparam ArrowColor #555555
|
||||
skinparam ArrowThickness 1.2
|
||||
|
||||
skinparam class {
|
||||
BackgroundColor #FAFAFA
|
||||
BorderColor #AAAAAA
|
||||
HeaderBackgroundColor #E8E8E8
|
||||
FontColor #222222
|
||||
StereotypeFontColor #666666
|
||||
AttributeFontColor #333333
|
||||
}
|
||||
|
||||
skinparam package {
|
||||
BackgroundColor #F5F5F5
|
||||
BorderColor #888888
|
||||
FontColor #333333
|
||||
FontStyle bold
|
||||
}
|
||||
|
||||
skinparam note {
|
||||
BackgroundColor #FFFDE7
|
||||
BorderColor #F9A825
|
||||
FontColor #444444
|
||||
FontSize 11
|
||||
}
|
||||
|
||||
' ============================================================
|
||||
' PATTERN 1 — BUILDER (Creational)
|
||||
' ============================================================
|
||||
|
||||
package "① Builder Pattern [Creational]" #EEF6EE {
|
||||
|
||||
interface "UserBuilder\n<<Builder>>" as UserBuilder {
|
||||
+ email(String) : UserBuilder
|
||||
+ password(String) : UserBuilder
|
||||
+ displayName(String) : UserBuilder
|
||||
+ uniqueUserId(String) : UserBuilder
|
||||
+ fcmToken(String) : UserBuilder
|
||||
+ role(Role) : UserBuilder
|
||||
+ build() : User
|
||||
}
|
||||
|
||||
class "User\n<<Entity>>" as User {
|
||||
- id : Long
|
||||
- email : String
|
||||
- password : String
|
||||
- displayName : String
|
||||
- uniqueUserId : String
|
||||
- fcmToken : String
|
||||
- role : Role
|
||||
- createdAt : Timestamp
|
||||
+ {static} builder() : UserBuilder
|
||||
}
|
||||
|
||||
class "FcmService\n<<Service>>" as FcmService {
|
||||
+ sendPushNotification(userId, payload)
|
||||
+ sendSosAlert(guardianId, lat, lng)
|
||||
- buildMessage(token, title, body) : Message
|
||||
}
|
||||
|
||||
class "AuthService\n<<Service>>" as AuthService {
|
||||
+ register(RegisterRequest) : AuthDataResponse
|
||||
+ login(LoginRequest) : AuthDataResponse
|
||||
- buildAuthResponse(user, tokens) : AuthDataResponse
|
||||
}
|
||||
|
||||
UserBuilder ..> User : <<creates>>
|
||||
User ..> UserBuilder : <<returns>>
|
||||
FcmService ..> "Firebase\nMessage.builder()" : uses
|
||||
AuthService ..> "AuthDataResponse\n.builder()" : uses
|
||||
}
|
||||
|
||||
note bottom of UserBuilder
|
||||
Lombok @Builder pada User.java
|
||||
Memisahkan konstruksi objek kompleks
|
||||
dari representasinya. Optional fields
|
||||
(displayName, uniqueUserId, fcmToken)
|
||||
tidak perlu constructor overloading.
|
||||
end note
|
||||
|
||||
' ============================================================
|
||||
' PATTERN 2 — SINGLETON (Creational)
|
||||
' ============================================================
|
||||
|
||||
package "② Singleton Pattern [Creational]" #EEF0FF {
|
||||
|
||||
class "GetIt\n<<ServiceLocator>>" as GetIt {
|
||||
- {static} _instance : GetIt
|
||||
+ {static} instance : GetIt
|
||||
+ registerSingleton<T>(T)
|
||||
+ get<T>() : T
|
||||
}
|
||||
|
||||
class "TtsService\n<<Singleton>>" as TtsService {
|
||||
- {static} _instance : TtsService
|
||||
- _flutterTts : FlutterTts
|
||||
+ speak(String text)
|
||||
+ speakImmediate(String text)
|
||||
+ stop()
|
||||
+ setLanguage(String lang)
|
||||
}
|
||||
|
||||
class "SttService\n<<Singleton>>" as SttService {
|
||||
- {static} _instance : SttService
|
||||
- _speechToText : SpeechToText
|
||||
+ startListening()
|
||||
+ stopListening()
|
||||
+ onResult : Stream<String>
|
||||
}
|
||||
|
||||
class "YoloDetector\n<<Singleton>>" as YoloDetector {
|
||||
- {static} _instance : YoloDetector
|
||||
- _interpreter : Interpreter
|
||||
- _labels : List<String>
|
||||
+ loadModel()
|
||||
+ detect(CameraImage) : List<DetectionResult>
|
||||
+ isRunning : bool
|
||||
}
|
||||
|
||||
class "WebSocketService\n<<Singleton>>" as WebSocketService {
|
||||
- {static} _instance : WebSocketService
|
||||
- _stompClient : StompClient
|
||||
+ connect(String token)
|
||||
+ subscribe(String destination)
|
||||
+ disconnect()
|
||||
}
|
||||
|
||||
class "AgoraService\n<<Singleton>>" as AgoraService {
|
||||
- {static} _instance : AgoraService
|
||||
- _engine : RtcEngine
|
||||
+ joinChannel(token, channel, uid)
|
||||
+ leaveChannel()
|
||||
+ muteLocalAudio(bool)
|
||||
}
|
||||
|
||||
GetIt --> TtsService : registers &\nmanages
|
||||
GetIt --> SttService : registers &\nmanages
|
||||
GetIt --> YoloDetector : registers &\nmanages
|
||||
GetIt --> WebSocketService : registers &\nmanages
|
||||
GetIt --> AgoraService : registers &\nmanages
|
||||
}
|
||||
|
||||
note right of GetIt
|
||||
injection_container.dart:
|
||||
sl.registerSingleton<TtsService>(TtsService())
|
||||
sl.registerSingleton<YoloDetector>(YoloDetector())
|
||||
sl.registerSingleton<WebSocketService>(WebSocketService())
|
||||
Satu instance untuk seluruh lifecycle app.
|
||||
end note
|
||||
|
||||
' ============================================================
|
||||
' PATTERN 3 — FACADE (Structural) — Flutter
|
||||
' ============================================================
|
||||
|
||||
package "③ Facade Pattern [Structural]" #FFF8E1 {
|
||||
|
||||
class "VoiceCommandHandler\n<<Facade>>" as VoiceCommandHandler {
|
||||
- _ttsService : TtsService
|
||||
- _sttService : SttService
|
||||
- _router : GoRouter
|
||||
- _walkGuideBloc : WalkGuideBloc
|
||||
- _sosBloc : SosBloc
|
||||
- _notifBloc : NotificationBloc
|
||||
+ processText(String command) : void
|
||||
- _matchCommand(String) : VoiceCommandKey?
|
||||
- _executeCommand(VoiceCommandKey) : void
|
||||
}
|
||||
|
||||
class "WalkGuideBloc\n<<Client>>" as WalkGuideBlocFacade {
|
||||
+ onVoiceCommand(String text)
|
||||
}
|
||||
|
||||
class "GuardianDashboardService\n<<Facade>>" as GuardianDashboardService {
|
||||
- _locationService : LocationService
|
||||
- _activityService : ActivityLogService
|
||||
- _sosService : SosService
|
||||
- _notifService : NotificationService
|
||||
+ getDashboard(guardianId) : DashboardResponse
|
||||
}
|
||||
|
||||
class "SttService " as SttServiceFacade <<Subsystem>>
|
||||
class "TtsService " as TtsServiceFacade <<Subsystem>>
|
||||
class "GoRouter\n<<Router>>" as GoRouterFacade <<Subsystem>>
|
||||
class "SosBloc " as SosBlocFacade <<Subsystem>>
|
||||
|
||||
class "LocationService\n<<Service>>" as LocationService <<Subsystem>>
|
||||
class "ActivityLogService\n<<Service>>" as ActivityService <<Subsystem>>
|
||||
class "SosService\n<<Service>>" as SosServiceFacade <<Subsystem>>
|
||||
|
||||
WalkGuideBlocFacade --> VoiceCommandHandler : processText()
|
||||
VoiceCommandHandler --> SttServiceFacade : delegates
|
||||
VoiceCommandHandler --> TtsServiceFacade : delegates
|
||||
VoiceCommandHandler --> GoRouterFacade : delegates
|
||||
VoiceCommandHandler --> SosBlocFacade : delegates
|
||||
|
||||
GuardianDashboardService --> LocationService : aggregates
|
||||
GuardianDashboardService --> ActivityService : aggregates
|
||||
GuardianDashboardService --> SosServiceFacade : aggregates
|
||||
}
|
||||
|
||||
note right of VoiceCommandHandler
|
||||
Client hanya panggil processText("start walkguide")
|
||||
tanpa perlu tahu kompleksitas di baliknya:
|
||||
matching phrase → execute command → TTS feedback
|
||||
→ route navigation → BLoC event dispatch
|
||||
end note
|
||||
|
||||
' ============================================================
|
||||
' PATTERN 4 — REPOSITORY / PROXY (Structural)
|
||||
' ============================================================
|
||||
|
||||
package "④ Repository Pattern [Structural — Proxy]" #FFF3E0 {
|
||||
|
||||
interface "WalkGuideRepository\n<<interface>>" as WalkGuideRepo {
|
||||
+ startSession() : Either<Failure, void>
|
||||
+ stopSession() : Either<Failure, void>
|
||||
+ logObstacle(ObstacleLogRequest) : Either<Failure, void>
|
||||
+ getObstacleLogs() : Either<Failure, List<ObstacleLog>>
|
||||
}
|
||||
|
||||
interface "ActivityLogRepository\n<<interface>>" as ActivityRepo {
|
||||
+ getLogs(page) : Either<Failure, List<ActivityLog>>
|
||||
+ savePending(ActivityLog) : Either<Failure, void>
|
||||
+ syncPending() : Either<Failure, void>
|
||||
}
|
||||
|
||||
class "WalkGuideRepositoryImpl\n<<Proxy>>" as WalkGuideRepoImpl {
|
||||
- _remoteDataSource : WalkGuideRemoteDataSource
|
||||
- _localDataSource : WalkGuideLocalDataSource
|
||||
- _connectivity : ConnectivityPlus
|
||||
+ startSession() : Either<Failure, void>
|
||||
+ logObstacle(req) : Either<Failure, void>
|
||||
- _isOnline() : bool
|
||||
}
|
||||
|
||||
class "ActivityLogRepositoryImpl\n<<Proxy>>" as ActivityRepoImpl {
|
||||
- _remoteDataSource : ActivityRemoteDataSource
|
||||
- _localDataSource : ActivityLocalDataSource
|
||||
+ getLogs(page) : Either<Failure, List>
|
||||
+ syncPending() : Either<Failure, void>
|
||||
}
|
||||
|
||||
class "WalkGuideRemoteDataSource\n<<Remote>>" as RemoteDSWalk {
|
||||
+ startSession() : void
|
||||
+ logObstacle(req) : void
|
||||
' POST /api/v1/user/obstacle
|
||||
}
|
||||
|
||||
class "WalkGuideLocalDataSource\n<<SQLite/Drift>>" as LocalDSWalk {
|
||||
+ cacheObstacle(ObstacleLog) : void
|
||||
+ getPendingLogs() : List<ObstacleLog>
|
||||
' Drift ORM — offline first
|
||||
}
|
||||
|
||||
WalkGuideRepo <|.. WalkGuideRepoImpl : implements
|
||||
ActivityRepo <|.. ActivityRepoImpl : implements
|
||||
WalkGuideRepoImpl --> RemoteDSWalk : online path
|
||||
WalkGuideRepoImpl --> LocalDSWalk : offline path
|
||||
}
|
||||
|
||||
note bottom of WalkGuideRepoImpl
|
||||
Proxy memutuskan sumber data:
|
||||
if (isOnline) → fetch remote API
|
||||
else → baca/tulis SQLite (Drift)
|
||||
Domain layer tidak tahu perbedaannya.
|
||||
dartz Either<Failure, Data> untuk typed error.
|
||||
end note
|
||||
|
||||
' ============================================================
|
||||
' PATTERN 5 — OBSERVER (Behavioral)
|
||||
' ============================================================
|
||||
|
||||
package "⑤ Observer Pattern [Behavioral]" #F3E5F5 {
|
||||
|
||||
abstract class "Bloc<Event, State>\n<<Subject>>" as BlocSubject {
|
||||
# stateController : StreamController<State>
|
||||
+ {abstract} on<E>(EventHandler)
|
||||
+ add(Event event)
|
||||
+ emit(State state)
|
||||
+ stream : Stream<State>
|
||||
}
|
||||
|
||||
class "WalkGuideBloc\n<<ConcreteSubject>>" as WalkGuideBlocObs {
|
||||
+ on<StartWalkGuide>(_onStart)
|
||||
+ on<StopWalkGuide>(_onStop)
|
||||
+ on<CameraFrameReceived>(_onFrame)
|
||||
+ on<ObstacleDetected>(_onObstacle)
|
||||
- _yoloDetector : YoloDetector
|
||||
- _ttsService : TtsService
|
||||
- _hapticService : HapticService
|
||||
}
|
||||
|
||||
class "BlocBuilder<WalkGuideBloc, WalkGuideState>\n<<Observer / Widget>>" as BlocBuilderWidget {
|
||||
+ builder(ctx, state) : Widget
|
||||
' Rebuilds UI on every state emission
|
||||
}
|
||||
|
||||
class "BlocListener<WalkGuideBloc, WalkGuideState>\n<<Observer / SideEffect>>" as BlocListenerWidget {
|
||||
+ listener(ctx, state) : void
|
||||
' Side effects: TTS, haptic, navigation
|
||||
}
|
||||
|
||||
class "WebSocketService\n<<Subject>>" as WebSocketObs {
|
||||
- _stompClient : StompClient
|
||||
+ subscribe(destination) : Stream<dynamic>
|
||||
+ onLocationUpdate : StreamController
|
||||
+ onSosAlert : StreamController
|
||||
+ onNotification : StreamController
|
||||
}
|
||||
|
||||
class "GuardianMapScreen\n<<Observer>>" as GuardianMapObs {
|
||||
+ onLocationUpdate(LocationData)
|
||||
' Updates flutter_map markers in real-time
|
||||
}
|
||||
|
||||
BlocSubject <|-- WalkGuideBlocObs : extends
|
||||
WalkGuideBlocObs --> BlocBuilderWidget : emits state\n(rebuilds UI)
|
||||
WalkGuideBlocObs --> BlocListenerWidget : emits state\n(side effects)
|
||||
WebSocketObs --> GuardianMapObs : notifies\nlive location
|
||||
}
|
||||
|
||||
note left of WebSocketObs
|
||||
Guardian subscribe topic:
|
||||
/topic/location/{userId} — live map
|
||||
/queue/sos/{guardianId} — SOS alert
|
||||
/queue/notif/{userId} — notifications
|
||||
STOMP over SockJS (stomp_dart_client)
|
||||
end note
|
||||
|
||||
' ============================================================
|
||||
' PATTERN 6 — STRATEGY (Behavioral)
|
||||
' ============================================================
|
||||
|
||||
package "⑥ Strategy Pattern [Behavioral]" #E8F5E9 {
|
||||
|
||||
interface "ObstacleAlertStrategy\n<<Strategy>>" as AlertStrategy {
|
||||
+ alert(DetectionResult result) : void
|
||||
}
|
||||
|
||||
class "TtsOnlyStrategy\n<<ConcreteStrategy>>" as TtsOnly {
|
||||
- _ttsService : TtsService
|
||||
+ alert(result) : void
|
||||
' TTS: "Caution! {label} {direction}. {distance}."
|
||||
}
|
||||
|
||||
class "TtsWithHapticStrategy\n<<ConcreteStrategy>>" as TtsHaptic {
|
||||
- _ttsService : TtsService
|
||||
- _hapticService : HapticService
|
||||
+ alert(result) : void
|
||||
' TTS + vibration pattern
|
||||
}
|
||||
|
||||
class "HapticOnlyStrategy\n<<ConcreteStrategy>>" as HapticOnly {
|
||||
- _hapticService : HapticService
|
||||
+ alert(result) : void
|
||||
' Vibration only (silent environment)
|
||||
}
|
||||
|
||||
class "ObstacleAnalyzer\n<<Context>>" as ObstacleAnalyzerCtx {
|
||||
- _strategy : ObstacleAlertStrategy
|
||||
- _aiConfig : AiConfig
|
||||
+ setStrategy(ObstacleAlertStrategy)
|
||||
+ analyze(List<DetectionResult>) : void
|
||||
+ prioritize(results) : DetectionResult
|
||||
+ calculateDirection(bbox) : Direction
|
||||
}
|
||||
|
||||
class "AiConfig\n<<Config>>" as AiConfigStrategy {
|
||||
+ alertMode : AlertMode
|
||||
+ confidenceThreshold : double
|
||||
+ maxInferenceFps : int
|
||||
+ enabledLabels : List<String>
|
||||
}
|
||||
|
||||
AlertStrategy <|.. TtsOnly : implements
|
||||
AlertStrategy <|.. TtsHaptic : implements
|
||||
AlertStrategy <|.. HapticOnly : implements
|
||||
ObstacleAnalyzerCtx --> AlertStrategy : uses
|
||||
ObstacleAnalyzerCtx ..> AiConfigStrategy : reads alertMode\nto select strategy
|
||||
}
|
||||
|
||||
note right of ObstacleAnalyzerCtx
|
||||
Guardian dapat ganti strategy dari dashboard:
|
||||
alertMode = TTS_ONLY | TTS_HAPTIC | HAPTIC_ONLY
|
||||
PUT /api/v1/guardian/ai-config
|
||||
Strategy dipilih runtime tanpa ubah kode utama.
|
||||
end note
|
||||
|
||||
' ============================================================
|
||||
' PATTERN 7 — CHAIN OF RESPONSIBILITY (Behavioral)
|
||||
' ============================================================
|
||||
|
||||
package "⑦ Chain of Responsibility [Behavioral]" #FCE4EC {
|
||||
|
||||
' --- Flutter: Dio Interceptor Chain ---
|
||||
abstract class "Interceptor\n<<Handler>>" as DioInterceptor {
|
||||
+ onRequest(options, handler)
|
||||
+ onResponse(response, handler)
|
||||
+ onError(error, handler)
|
||||
}
|
||||
|
||||
class "AuthInterceptor\n<<ConcreteHandler>>" as AuthInterceptor {
|
||||
- _secureStorage : FlutterSecureStorage
|
||||
+ onRequest(options, handler)
|
||||
' Attach Bearer token ke setiap request
|
||||
+ onError(error, handler)
|
||||
' Jika 401: refresh token → retry request
|
||||
}
|
||||
|
||||
class "ErrorInterceptor\n<<ConcreteHandler>>" as ErrorInterceptor {
|
||||
+ onError(error, handler)
|
||||
' Map HTTP error codes → Failure domain objects
|
||||
' 401 → UnauthorizedFailure
|
||||
' 404 → NotFoundFailure
|
||||
' 500 → ServerFailure
|
||||
}
|
||||
|
||||
class "LogInterceptor\n<<ConcreteHandler>>" as LogInterceptorFlutter {
|
||||
+ onRequest(options, handler)
|
||||
+ onResponse(response, handler)
|
||||
+ onError(error, handler)
|
||||
' Print request/response untuk debugging
|
||||
}
|
||||
|
||||
class "ApiClient\n<<Dio>>" as ApiClient {
|
||||
- _dio : Dio
|
||||
+ get(url) : Response
|
||||
+ post(url, data) : Response
|
||||
+ put(url, data) : Response
|
||||
' Interceptors dieksekusi berurutan
|
||||
}
|
||||
|
||||
' --- Backend: Spring Security Filter Chain ---
|
||||
abstract class "OncePerRequestFilter\n<<Handler>>" as SpringFilter {
|
||||
+ {abstract} doFilterInternal(req, res, chain)
|
||||
+ doFilter(req, res, chain)
|
||||
}
|
||||
|
||||
class "JwtAuthFilter\n<<ConcreteHandler>>" as JwtAuthFilter {
|
||||
- _jwtUtil : JwtUtil
|
||||
- _userDetailsService : UserDetailsService
|
||||
+ doFilterInternal(req, res, chain)
|
||||
' Extract JWT → validate → set SecurityContext
|
||||
' Jika invalid: 401 Unauthorized langsung
|
||||
}
|
||||
|
||||
class "SecurityConfig\n<<Config/Handler>>" as SecurityConfigChain {
|
||||
+ securityFilterChain(http) : SecurityFilterChain
|
||||
' /auth/** → permitAll
|
||||
' /api/v1/** → authenticated
|
||||
' ROLE_GUARDIAN vs ROLE_USER routing
|
||||
}
|
||||
|
||||
class "Controller\n<<Endpoint>>" as ControllerChain {
|
||||
+ handleRequest(request) : ResponseEntity
|
||||
' Request tiba hanya jika lolos semua filter
|
||||
}
|
||||
|
||||
DioInterceptor <|-- AuthInterceptor : extends
|
||||
DioInterceptor <|-- ErrorInterceptor : extends
|
||||
DioInterceptor <|-- LogInterceptorFlutter : extends
|
||||
|
||||
ApiClient --> AuthInterceptor : chain[0]
|
||||
AuthInterceptor --> ErrorInterceptor : passes to\nchain[1]
|
||||
ErrorInterceptor --> LogInterceptorFlutter : passes to\nchain[2]
|
||||
|
||||
SpringFilter <|-- JwtAuthFilter : extends
|
||||
JwtAuthFilter --> SecurityConfigChain : passes to
|
||||
SecurityConfigChain --> ControllerChain : passes to\n(if authorized)
|
||||
}
|
||||
|
||||
note bottom of ApiClient
|
||||
Flutter — Dio interceptor chain:
|
||||
HTTP Request → AuthInterceptor → ErrorInterceptor → LogInterceptor → Response
|
||||
Setiap handler bisa: proses, modifikasi, atau blok request.
|
||||
AuthInterceptor otomatis refresh JWT jika expired (401).
|
||||
end note
|
||||
|
||||
note bottom of JwtAuthFilter
|
||||
Backend — Spring Security filter chain:
|
||||
HTTP Request → JwtAuthFilter → SecurityConfig → Controller
|
||||
JwtAuthFilter extract & validasi Bearer token setiap request.
|
||||
Jika invalid → langsung 401 tanpa lanjut ke controller.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
994
ooad-docs/DESIGN_PATTERNS_NOTES.md
Normal file
994
ooad-docs/DESIGN_PATTERNS_NOTES.md
Normal file
@ -0,0 +1,994 @@
|
||||
# Design Patterns — WalkGuide
|
||||
|
||||
> **Flutter × Spring Boot × In-Device AI**
|
||||
> Dokumentasi 7 Design Patterns (GoF) · Wajib ≥4, min. 1 per kategori
|
||||
|
||||
---
|
||||
|
||||
## Daftar Isi
|
||||
|
||||
1. [Gambaran Umum](#gambaran-umum)
|
||||
2. [Creational Patterns](#creational-patterns)
|
||||
- [Builder Pattern](#1-builder-pattern)
|
||||
- [Singleton Pattern](#2-singleton-pattern)
|
||||
3. [Structural Patterns](#structural-patterns)
|
||||
- [Facade Pattern](#3-facade-pattern)
|
||||
- [Repository Pattern (Proxy)](#4-repository-pattern-proxy)
|
||||
4. [Behavioral Patterns](#behavioral-patterns)
|
||||
- [Observer Pattern](#5-observer-pattern)
|
||||
- [Strategy Pattern](#6-strategy-pattern)
|
||||
- [Chain of Responsibility Pattern](#7-chain-of-responsibility-pattern)
|
||||
5. [Ringkasan Matriks](#ringkasan-matriks)
|
||||
|
||||
---
|
||||
|
||||
## Gambaran Umum
|
||||
|
||||
WalkGuide mengimplementasikan **7 design patterns GoF** yang tersebar di seluruh lapisan sistem — Flutter (mobile), Spring Boot (backend), dan lapisan AI on-device. Setiap pattern dipilih berdasarkan kebutuhan nyata arsitektur, bukan sekadar pemenuhan syarat akademis.
|
||||
|
||||
| # | Pattern | Kategori | Layer |
|
||||
|---|---------|----------|-------|
|
||||
| 1 | Builder | Creational | Backend (Spring Boot) |
|
||||
| 2 | Singleton | Creational | Flutter |
|
||||
| 3 | Facade | Structural | Flutter + Backend |
|
||||
| 4 | Repository (Proxy) | Structural | Flutter |
|
||||
| 5 | Observer | Behavioral | Flutter + Backend |
|
||||
| 6 | Strategy | Behavioral | Flutter + Backend |
|
||||
| 7 | Chain of Responsibility | Behavioral | Flutter + Backend |
|
||||
|
||||
---
|
||||
|
||||
## Creational Patterns
|
||||
|
||||
Creational patterns mengatur **cara objek diciptakan**, memisahkan logika konstruksi dari penggunaan objek tersebut.
|
||||
|
||||
---
|
||||
|
||||
### 1. Builder Pattern
|
||||
|
||||
**Lokasi:** `entity/User.java`, `service/FcmService.java`, `service/AuthService.java`
|
||||
|
||||
#### Masalah yang Diselesaikan
|
||||
|
||||
Entitas `User` memiliki banyak field opsional — `displayName`, `uniqueUserId`, `fcmToken` — yang tidak selalu diisi. Tanpa Builder, constructor akan memiliki terlalu banyak parameter sehingga kode sulit dibaca dan rawan salah urutan argumen.
|
||||
|
||||
#### Solusi
|
||||
|
||||
Lombok `@Builder` pada `User.java` membangkitkan builder class secara otomatis. `FcmService` menggunakan `Message.builder()` dari Firebase Admin SDK untuk menyusun payload notifikasi secara berantai. `AuthService` membangun `AuthDataResponse` step-by-step.
|
||||
|
||||
#### Implementasi
|
||||
|
||||
```java
|
||||
// entity/User.java
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@Data
|
||||
@Builder // ← Builder Pattern
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class User {
|
||||
@Id @GeneratedValue
|
||||
private Long id;
|
||||
|
||||
private String email;
|
||||
private String password;
|
||||
private String displayName; // opsional
|
||||
private String uniqueUserId; // hanya ROLE_USER
|
||||
private String fcmToken; // update tiap login
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Role role;
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// service/AuthService.java — bangun response step-by-step
|
||||
private AuthDataResponse buildAuthResponse(User user, String accessToken, String refreshToken) {
|
||||
return AuthDataResponse.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.role(user.getRole().name())
|
||||
.userId(user.getId())
|
||||
.displayName(user.getDisplayName())
|
||||
.uniqueUserId(user.getUniqueUserId()) // null jika GUARDIAN
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// service/FcmService.java — Firebase Message builder
|
||||
private Message buildSosMessage(String fcmToken, double lat, double lng) {
|
||||
return Message.builder()
|
||||
.setToken(fcmToken)
|
||||
.setNotification(Notification.builder()
|
||||
.setTitle("🚨 SOS ALERT!")
|
||||
.setBody("User butuh bantuan! Lokasi: " + lat + ", " + lng)
|
||||
.build())
|
||||
.putData("type", "SOS_ALERT")
|
||||
.putData("lat", String.valueOf(lat))
|
||||
.putData("lng", String.valueOf(lng))
|
||||
.setAndroidConfig(AndroidConfig.builder()
|
||||
.setPriority(AndroidConfig.Priority.HIGH)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
```
|
||||
|
||||
#### Diagram
|
||||
|
||||
```
|
||||
«Builder»
|
||||
UserBuilder
|
||||
┌─────────────────────┐
|
||||
│ + email(String) │
|
||||
│ + password(String) │
|
||||
│ + displayName(...) │
|
||||
│ + uniqueUserId(...) │
|
||||
│ + fcmToken(...) │
|
||||
│ + build() : User │
|
||||
└──────────┬──────────┘
|
||||
│ creates
|
||||
▼
|
||||
«Entity»
|
||||
User
|
||||
┌─────────────────────┐
|
||||
│ - id : Long │
|
||||
│ - email : String │
|
||||
│ - displayName │
|
||||
│ - uniqueUserId │
|
||||
│ - fcmToken │
|
||||
│ + builder() │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
#### Keuntungan dalam WalkGuide
|
||||
|
||||
- Kode registrasi user bersih meski ada field yang `null` (Guardian tidak punya `uniqueUserId`).
|
||||
- Firebase `Message` payload bisa disusun kondisional tanpa if-else bertumpuk.
|
||||
- Mudah di-test karena konstruksi objek eksplisit dan readable.
|
||||
|
||||
---
|
||||
|
||||
### 2. Singleton Pattern
|
||||
|
||||
**Lokasi:** `app/injection_container.dart`, semua `core/services/*.dart`
|
||||
|
||||
#### Masalah yang Diselesaikan
|
||||
|
||||
`YoloDetector` memuat model `.tflite` ~6 MB ke memori. `TtsService` mengelola engine TTS. `WebSocketService` mempertahankan satu koneksi STOMP. Membuat multiple instance dari service-service ini akan membuang memori dan menyebabkan konflik resource.
|
||||
|
||||
#### Solusi
|
||||
|
||||
Semua heavy service di-register sebagai singleton melalui `GetIt` service locator. `GetIt` menjamin hanya ada satu instance untuk seluruh lifecycle aplikasi.
|
||||
|
||||
#### Implementasi
|
||||
|
||||
```dart
|
||||
// app/injection_container.dart
|
||||
final sl = GetIt.instance;
|
||||
|
||||
Future<void> initDependencies() async {
|
||||
// ── Singletons (dibuat sekali, hidup selamanya) ──────────────
|
||||
sl.registerSingleton<TtsService>(TtsService());
|
||||
sl.registerSingleton<SttService>(SttService());
|
||||
sl.registerSingleton<YoloDetector>(YoloDetector());
|
||||
sl.registerSingleton<WebSocketService>(WebSocketService());
|
||||
sl.registerSingleton<AgoraService>(AgoraService());
|
||||
sl.registerSingleton<HapticService>(HapticService());
|
||||
|
||||
// ── Factories (baru tiap pakai) ──────────────────────────────
|
||||
sl.registerFactory<WalkGuideBloc>(
|
||||
() => WalkGuideBloc(
|
||||
yoloDetector: sl(),
|
||||
ttsService: sl(), // inject singleton yang sama
|
||||
hapticService: sl(),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// core/ai/yolo_detector.dart — resource-heavy singleton
|
||||
class YoloDetector {
|
||||
Interpreter? _interpreter;
|
||||
List<String> _labels = [];
|
||||
bool get isRunning => _isRunning;
|
||||
bool _isRunning = false;
|
||||
|
||||
Future<void> loadModel() async {
|
||||
final modelPath = await _getModelPath('assets/models/yolov8n.tflite');
|
||||
_interpreter = await Interpreter.fromAsset(modelPath,
|
||||
options: InterpreterOptions()..useNnApiForAndroid = true);
|
||||
_labels = await _loadLabels('assets/models/labels.txt');
|
||||
}
|
||||
|
||||
Future<List<DetectionResult>> detect(CameraImage frame) async {
|
||||
if (_isRunning) return []; // skip frame, model sedang dipakai
|
||||
_isRunning = true;
|
||||
try {
|
||||
// YUV420 → RGB → resize 640×640 → normalize → inference → NMS
|
||||
return await compute(_runInference, frame);
|
||||
} finally {
|
||||
_isRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Diagram
|
||||
|
||||
```
|
||||
GetIt (Service Locator)
|
||||
┌────────────────────────────────┐
|
||||
│ registerSingleton<T>(T) │
|
||||
│ get<T>() : T │
|
||||
└──┬──────┬──────┬──────┬───────┘
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
Yolo TTS STT WebSocket
|
||||
Detector Service Service Service
|
||||
(6MB AI) (TTS (Always (STOMP
|
||||
model) engine) listen) connect)
|
||||
```
|
||||
|
||||
#### Keuntungan dalam WalkGuide
|
||||
|
||||
- Model YOLO hanya di-load satu kali saat startup, bukan setiap buka layar.
|
||||
- Semua BLoC berbagi instance TTS yang sama — tidak ada suara bertumpuk.
|
||||
- Koneksi WebSocket tidak dibuat ulang setiap navigasi.
|
||||
|
||||
---
|
||||
|
||||
## Structural Patterns
|
||||
|
||||
Structural patterns mengatur **cara objek dan kelas disusun** menjadi struktur yang lebih besar.
|
||||
|
||||
---
|
||||
|
||||
### 3. Facade Pattern
|
||||
|
||||
**Lokasi (Flutter):** `core/services/voice_command_handler.dart`
|
||||
**Lokasi (Backend):** `service/GuardianDashboardService.java`
|
||||
|
||||
#### Masalah yang Diselesaikan
|
||||
|
||||
Memproses satu perintah suara seperti `"Send SOS"` membutuhkan koordinasi antara: STT service, command matcher, SOS BLoC, GPS service, TTS feedback, dan router navigasi. Tanpa Facade, setiap widget harus tahu dan memanggil semua subsystem tersebut sendiri.
|
||||
|
||||
#### Solusi
|
||||
|
||||
`VoiceCommandHandler` bertindak sebagai Facade — satu antarmuka tunggal yang menyembunyikan seluruh kompleksitas di baliknya. Client hanya perlu memanggil `processText(string)`.
|
||||
|
||||
Di backend, `GuardianDashboardService` adalah Facade yang mengagregasi data dari `LocationService`, `ActivityLogService`, `SosService`, dan `NotificationService` menjadi satu response `DashboardResponse`.
|
||||
|
||||
#### Implementasi
|
||||
|
||||
```dart
|
||||
// core/services/voice_command_handler.dart
|
||||
class VoiceCommandHandler {
|
||||
final TtsService _ttsService;
|
||||
final GoRouter _router;
|
||||
final WalkGuideBloc _walkGuideBloc;
|
||||
final SosBloc _sosBloc;
|
||||
final NotificationBloc _notifBloc;
|
||||
List<VoiceCommandConfig> _commandConfigs = [];
|
||||
|
||||
// ─── Satu-satunya method yang perlu dipanggil client ─────────
|
||||
Future<void> processText(String rawText) async {
|
||||
final normalized = rawText.toLowerCase().trim();
|
||||
final key = _matchCommand(normalized);
|
||||
if (key == null) return;
|
||||
await _executeCommand(key);
|
||||
}
|
||||
|
||||
VoiceCommandKey? _matchCommand(String text) {
|
||||
for (final config in _commandConfigs) {
|
||||
if (!config.enabled) continue;
|
||||
if (text.contains(config.triggerPhrase.toLowerCase())) {
|
||||
return config.commandKey;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _executeCommand(VoiceCommandKey key) async {
|
||||
switch (key) {
|
||||
case VoiceCommandKey.START_WALKGUIDE:
|
||||
_walkGuideBloc.add(StartWalkGuide());
|
||||
case VoiceCommandKey.SEND_SOS:
|
||||
_sosBloc.add(TriggerSos());
|
||||
await _ttsService.speakImmediate('SOS sent. Guardian has been alerted.');
|
||||
case VoiceCommandKey.WHERE_AM_I:
|
||||
final pos = await Geolocator.getCurrentPosition();
|
||||
await _ttsService.speak('You are at ${pos.latitude}, ${pos.longitude}');
|
||||
case VoiceCommandKey.CALL_GUARDIAN:
|
||||
_router.push('/user/call');
|
||||
// ... 10 command lainnya
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// service/GuardianDashboardService.java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GuardianDashboardService {
|
||||
|
||||
private final LocationService locationService;
|
||||
private final ActivityLogService activityLogService;
|
||||
private final SosService sosService;
|
||||
private final NotificationService notificationService;
|
||||
private final PairingService pairingService;
|
||||
|
||||
// ─── Facade: satu method agregasi semua data dashboard ───────
|
||||
public DashboardResponse getDashboard(Long guardianId) {
|
||||
Long userId = pairingService.getPairedUserId(guardianId);
|
||||
|
||||
return DashboardResponse.builder()
|
||||
.lastLocation(locationService.getLastLocation(userId))
|
||||
.recentActivities(activityLogService.getRecent(userId, 10))
|
||||
.activeSosCount(sosService.countActive(userId))
|
||||
.unreadNotifCount(notificationService.countUnread(userId))
|
||||
.userStatus(locationService.getUserStatus(userId))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Diagram
|
||||
|
||||
```
|
||||
Client (WalkGuideBloc)
|
||||
│
|
||||
│ processText("start walkguide")
|
||||
▼
|
||||
VoiceCommandHandler ← FACADE
|
||||
┌─────────────────────────────────────┐
|
||||
│ _matchCommand(text) │
|
||||
│ _executeCommand(key) │
|
||||
└──┬──────┬──────┬──────┬────────────┘
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
TTS WalkGuide Sos GoRouter
|
||||
Service Bloc Bloc (navigate)
|
||||
↑ ↑ ↑ ↑
|
||||
└──────┴──────┴──────┘
|
||||
Subsystems
|
||||
(client tidak tahu ini)
|
||||
```
|
||||
|
||||
#### Keuntungan dalam WalkGuide
|
||||
|
||||
- Widget cukup panggil satu method — tidak perlu inject 6 dependency berbeda.
|
||||
- Mudah menambah command baru tanpa mengubah semua widget yang memanggil handler.
|
||||
- Backend: satu endpoint `GET /guardian/dashboard` mengembalikan semua data yang dibutuhkan, mengurangi jumlah request dari Flutter.
|
||||
|
||||
---
|
||||
|
||||
### 4. Repository Pattern (Proxy)
|
||||
|
||||
**Lokasi:** `features/*/data/repositories/*_repository_impl.dart`
|
||||
|
||||
#### Masalah yang Diselesaikan
|
||||
|
||||
WalkGuide harus berjalan saat offline (area tanpa sinyal). Obstacle log dan activity log harus tetap tersimpan meski tidak ada koneksi, lalu di-sync ke backend saat online kembali. Domain layer tidak boleh tahu apakah data berasal dari API atau cache lokal.
|
||||
|
||||
#### Solusi
|
||||
|
||||
Setiap `*_repository_impl.dart` bertindak sebagai **Proxy** yang mengimplementasikan abstract interface dari domain layer. Proxy memutuskan: ambil dari SQLite (Drift) saat offline, atau dari REST API saat online. Domain layer — termasuk BLoC dan use case — hanya kenal interface, tidak tahu implementasinya.
|
||||
|
||||
#### Implementasi
|
||||
|
||||
```dart
|
||||
// features/walk_guide/domain/repositories/walk_guide_repository.dart
|
||||
abstract class WalkGuideRepository {
|
||||
Future<Either<Failure, void>> startSession();
|
||||
Future<Either<Failure, void>> stopSession();
|
||||
Future<Either<Failure, void>> logObstacle(ObstacleLogRequest request);
|
||||
Future<Either<Failure, List<ObstacleLog>>> getObstacleLogs({int page = 0});
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// features/walk_guide/data/repositories/walk_guide_repository_impl.dart
|
||||
class WalkGuideRepositoryImpl implements WalkGuideRepository {
|
||||
final WalkGuideRemoteDataSource _remote;
|
||||
final WalkGuideLocalDataSource _local; // SQLite/Drift
|
||||
final ConnectivityPlus _connectivity;
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> logObstacle(ObstacleLogRequest request) async {
|
||||
try {
|
||||
if (await _isOnline()) {
|
||||
// ── Online: kirim langsung ke backend ──────────────────
|
||||
await _remote.logObstacle(request);
|
||||
return const Right(null);
|
||||
} else {
|
||||
// ── Offline: simpan di SQLite dulu ─────────────────────
|
||||
await _local.cacheObstacle(request);
|
||||
return const Right(null); // domain layer tidak tahu bedanya
|
||||
}
|
||||
} catch (e) {
|
||||
return Left(ServerFailure(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<ObstacleLog>>> getObstacleLogs({int page = 0}) async {
|
||||
if (await _isOnline()) {
|
||||
try {
|
||||
final logs = await _remote.fetchObstacleLogs(page: page);
|
||||
await _local.cacheLogs(logs); // update cache
|
||||
return Right(logs);
|
||||
} catch (_) {
|
||||
// fallback ke cache jika request gagal
|
||||
final cached = await _local.getCachedLogs(page: page);
|
||||
return Right(cached);
|
||||
}
|
||||
}
|
||||
final cached = await _local.getCachedLogs(page: page);
|
||||
return Right(cached);
|
||||
}
|
||||
|
||||
Future<bool> _isOnline() async {
|
||||
final result = await _connectivity.checkConnectivity();
|
||||
return result != ConnectivityResult.none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// Sync pending saat koneksi kembali
|
||||
class SyncPendingLogsUseCase {
|
||||
final WalkGuideRepository _repository;
|
||||
|
||||
Future<Either<Failure, void>> call() async {
|
||||
final pending = await _repository.getPendingLogs();
|
||||
for (final log in pending) {
|
||||
await _repository.syncToRemote(log);
|
||||
}
|
||||
return const Right(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Diagram
|
||||
|
||||
```
|
||||
Domain Layer (BLoC/UseCase)
|
||||
│
|
||||
│ logObstacle(request)
|
||||
▼
|
||||
«interface» WalkGuideRepository
|
||||
│ implements
|
||||
▼
|
||||
WalkGuideRepositoryImpl ← PROXY
|
||||
┌──────────────────────────────┐
|
||||
│ if (isOnline) │
|
||||
│ → RemoteDataSource (API) │
|
||||
│ else │
|
||||
│ → LocalDataSource (SQLite)│
|
||||
└──────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
REST API SQLite/Drift
|
||||
(online) (offline cache)
|
||||
```
|
||||
|
||||
#### Keuntungan dalam WalkGuide
|
||||
|
||||
- Tunanetra tetap bisa menggunakan WalkGuide detection meski sinyal hilang.
|
||||
- Perubahan strategi caching tidak mempengaruhi BLoC atau use case sama sekali.
|
||||
- `dartz Either<Failure, Data>` memastikan error handling eksplisit di setiap layer.
|
||||
|
||||
---
|
||||
|
||||
## Behavioral Patterns
|
||||
|
||||
Behavioral patterns mengatur **cara objek berkomunikasi dan berinteraksi** satu sama lain.
|
||||
|
||||
---
|
||||
|
||||
### 5. Observer Pattern
|
||||
|
||||
**Lokasi:** `features/*/presentation/bloc/*.dart`, `core/services/websocket_service.dart`
|
||||
|
||||
#### Masalah yang Diselesaikan
|
||||
|
||||
Ketika YOLO mendeteksi obstacle, puluhan komponen perlu bereaksi: kamera overlay perlu menampilkan bounding box, TTS perlu berbicara, haptic perlu bergetar, dan backend perlu dicatat. Menghubungkan semua komponen ini secara langsung akan menciptakan coupling yang sangat ketat.
|
||||
|
||||
#### Solusi
|
||||
|
||||
BLoC pattern adalah implementasi Observer. `WalkGuideBloc` berperan sebagai **Subject** yang menyimpan state dan memancarkan (emit) state baru setiap ada perubahan. Widget (`BlocBuilder`, `BlocConsumer`, `BlocListener`) berperan sebagai **Observer** yang otomatis bereaksi terhadap perubahan state. `WebSocketService` juga menggunakan Observer untuk meneruskan update lokasi real-time dari User ke Guardian.
|
||||
|
||||
#### Implementasi
|
||||
|
||||
```dart
|
||||
// features/walk_guide/presentation/bloc/walk_guide_bloc.dart
|
||||
class WalkGuideBloc extends Bloc<WalkGuideEvent, WalkGuideState> {
|
||||
final YoloDetector _yoloDetector;
|
||||
final TtsService _ttsService;
|
||||
final HapticService _hapticService;
|
||||
final WalkGuideRepository _repository;
|
||||
|
||||
WalkGuideBloc({...}) : super(WalkGuideIdle()) {
|
||||
on<StartWalkGuide>(_onStart);
|
||||
on<StopWalkGuide>(_onStop);
|
||||
on<CameraFrameReceived>(_onFrame);
|
||||
}
|
||||
|
||||
Future<void> _onFrame(
|
||||
CameraFrameReceived event,
|
||||
Emitter<WalkGuideState> emit,
|
||||
) async {
|
||||
if (_yoloDetector.isRunning) return; // throttle
|
||||
|
||||
final results = await _yoloDetector.detect(event.frame);
|
||||
if (results.isEmpty) return;
|
||||
|
||||
final top = _prioritize(results);
|
||||
|
||||
// ─── Emit state baru → semua Observer bereaksi ────────────
|
||||
emit(WalkGuideObstacleDetected(
|
||||
label: top.label,
|
||||
direction: top.direction,
|
||||
distance: top.estimatedDistance,
|
||||
confidence: top.confidence,
|
||||
boundingBox: top.boundingBox,
|
||||
));
|
||||
|
||||
// Side effects
|
||||
await _ttsService.speakImmediate(_buildTtsMessage(top));
|
||||
_hapticService.obstacleVeryClose();
|
||||
await _repository.logObstacle(ObstacleLogRequest.from(top));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// features/walk_guide/presentation/screens/walk_guide_screen.dart
|
||||
class WalkGuideScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// ── Observer 1: rebuild kamera overlay ────────────────
|
||||
BlocBuilder<WalkGuideBloc, WalkGuideState>(
|
||||
builder: (context, state) {
|
||||
if (state is WalkGuideObstacleDetected) {
|
||||
return DetectionOverlay(
|
||||
boundingBox: state.boundingBox,
|
||||
label: state.label,
|
||||
direction: state.direction,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
// ── Observer 2: side effects (navigasi, snackbar) ─────
|
||||
BlocListener<WalkGuideBloc, WalkGuideState>(
|
||||
listener: (context, state) {
|
||||
if (state is WalkGuideError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.message)),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const CameraPreview(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// core/services/websocket_service.dart — Observer untuk Guardian
|
||||
class WebSocketService {
|
||||
StompClient? _stompClient;
|
||||
final StreamController<LocationData> _locationController =
|
||||
StreamController.broadcast();
|
||||
|
||||
Stream<LocationData> get onLocationUpdate => _locationController.stream;
|
||||
|
||||
void subscribeToUserLocation(String userId) {
|
||||
_stompClient?.subscribe(
|
||||
destination: '/topic/location/$userId',
|
||||
callback: (frame) {
|
||||
final data = LocationData.fromJson(jsonDecode(frame.body!));
|
||||
_locationController.add(data); // notify semua Observer
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// guardian_map_screen.dart — Observer menerima update
|
||||
class GuardianMapScreen extends StatefulWidget {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
sl<WebSocketService>().onLocationUpdate.listen((location) {
|
||||
setState(() => _userMarker = LatLng(location.lat, location.lng));
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Diagram
|
||||
|
||||
```
|
||||
WalkGuideBloc ← SUBJECT
|
||||
┌─────────────────┐
|
||||
│ emit(state) │──────────────────────────────┐
|
||||
└─────────────────┘ │
|
||||
│ │
|
||||
┌────────┴────────┐ ┌──────────┴──────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
BlocBuilder BlocListener WebSocketService GuardianMap
|
||||
(UI rebuild) (side effects) (STOMP Subject) (Observer)
|
||||
↑ ↑ │
|
||||
OBSERVER OBSERVER │ notify
|
||||
▼
|
||||
Guardian's map
|
||||
updates live
|
||||
```
|
||||
|
||||
#### Keuntungan dalam WalkGuide
|
||||
|
||||
- Menambah Observer baru (misal: logging screen) tidak memerlukan perubahan pada BLoC.
|
||||
- Guardian melihat posisi User real-time di peta tanpa polling setiap detik.
|
||||
- BLoC stream bisa di-test dengan `bloc_test` package secara terisolasi.
|
||||
|
||||
---
|
||||
|
||||
### 6. Strategy Pattern
|
||||
|
||||
**Lokasi (Flutter):** `core/ai/obstacle_analyzer.dart`
|
||||
**Lokasi (Backend):** `service/ObstacleAlertStrategyService.java`
|
||||
|
||||
#### Masalah yang Diselesaikan
|
||||
|
||||
Cara WalkGuide memperingatkan user tentang obstacle berbeda-beda: ada yang butuh TTS saja, ada yang perlu TTS + getaran, ada yang hanya getaran (lingkungan bising). Guardian harus bisa mengganti mode ini dari dashboard tanpa update APK.
|
||||
|
||||
#### Solusi
|
||||
|
||||
`ObstacleAlertStrategy` interface mendefinisikan kontrak peringatan. Tiga implementasi konkret (`TtsOnlyStrategy`, `TtsWithHapticStrategy`, `HapticOnlyStrategy`) dapat dipertukarkan runtime. `ObstacleAnalyzer` sebagai Context menyimpan referensi ke strategy aktif dan mendelegasikan eksekusi tanpa tahu implementasinya.
|
||||
|
||||
#### Implementasi
|
||||
|
||||
```dart
|
||||
// core/ai/strategy/obstacle_alert_strategy.dart
|
||||
abstract class ObstacleAlertStrategy {
|
||||
Future<void> alert(DetectionResult result);
|
||||
}
|
||||
|
||||
// ── Concrete Strategy 1 ───────────────────────────────────────
|
||||
class TtsOnlyStrategy implements ObstacleAlertStrategy {
|
||||
final TtsService _ttsService;
|
||||
|
||||
@override
|
||||
Future<void> alert(DetectionResult result) async {
|
||||
final msg = 'Caution! ${result.label} ${result.direction}. '
|
||||
'${result.estimatedDistance}. Please stop.';
|
||||
await _ttsService.speakImmediate(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Concrete Strategy 2 ───────────────────────────────────────
|
||||
class TtsWithHapticStrategy implements ObstacleAlertStrategy {
|
||||
final TtsService _ttsService;
|
||||
final HapticService _hapticService;
|
||||
|
||||
@override
|
||||
Future<void> alert(DetectionResult result) async {
|
||||
await Future.wait([
|
||||
_ttsService.speakImmediate('Caution! ${result.label} ahead.'),
|
||||
_hapticService.obstaclePattern(result.estimatedDistance),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Concrete Strategy 3 ───────────────────────────────────────
|
||||
class HapticOnlyStrategy implements ObstacleAlertStrategy {
|
||||
final HapticService _hapticService;
|
||||
|
||||
@override
|
||||
Future<void> alert(DetectionResult result) async {
|
||||
await _hapticService.obstaclePattern(result.estimatedDistance);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// core/ai/obstacle_analyzer.dart — Context
|
||||
class ObstacleAnalyzer {
|
||||
ObstacleAlertStrategy _strategy;
|
||||
final AiConfig _config;
|
||||
|
||||
// ─── Ganti strategy runtime tanpa ubah kode lain ─────────────
|
||||
void setStrategy(ObstacleAlertStrategy strategy) {
|
||||
_strategy = strategy;
|
||||
}
|
||||
|
||||
Future<void> analyze(List<DetectionResult> results) async {
|
||||
if (results.isEmpty) return;
|
||||
final top = prioritize(results);
|
||||
if (top.confidence < _config.confidenceThreshold) return;
|
||||
await _strategy.alert(top); // delegasi ke strategy aktif
|
||||
}
|
||||
|
||||
DetectionResult prioritize(List<DetectionResult> results) {
|
||||
return results.reduce((a, b) =>
|
||||
_dangerScore(a) > _dangerScore(b) ? a : b);
|
||||
}
|
||||
|
||||
double _dangerScore(DetectionResult r) {
|
||||
const distanceScore = {
|
||||
'Very Close': 4.0, 'Close': 3.0, 'Medium': 2.0, 'Far': 1.0
|
||||
};
|
||||
return r.confidence * (distanceScore[r.estimatedDistance] ?? 1.0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// Saat AiConfig diupdate dari Guardian
|
||||
void _onAiConfigUpdated(AiConfig config) {
|
||||
final strategy = switch (config.alertMode) {
|
||||
AlertMode.ttsOnly => TtsOnlyStrategy(_ttsService),
|
||||
AlertMode.ttsAndHaptic => TtsWithHapticStrategy(_ttsService, _hapticService),
|
||||
AlertMode.hapticOnly => HapticOnlyStrategy(_hapticService),
|
||||
};
|
||||
sl<ObstacleAnalyzer>().setStrategy(strategy);
|
||||
}
|
||||
```
|
||||
|
||||
#### Diagram
|
||||
|
||||
```
|
||||
«interface»
|
||||
ObstacleAlertStrategy
|
||||
┌─────────────────┐
|
||||
│ + alert(result) │
|
||||
└────────┬────────┘
|
||||
│ implements
|
||||
┌──────┼──────┐
|
||||
▼ ▼ ▼
|
||||
TtsOnly TTS+ Haptic
|
||||
Strategy Haptic Only
|
||||
Strat. Strategy
|
||||
|
||||
ObstacleAnalyzer (Context)
|
||||
┌────────────────────────────┐
|
||||
│ - _strategy: Strategy │◄── setStrategy()
|
||||
│ + analyze(results) │ ▲
|
||||
│ → _strategy.alert(top) │ │
|
||||
└────────────────────────────┘ AiConfig.alertMode
|
||||
(dari Guardian)
|
||||
```
|
||||
|
||||
#### Keuntungan dalam WalkGuide
|
||||
|
||||
- Guardian bisa mengganti mode alert dari `PUT /guardian/ai-config` tanpa restart app.
|
||||
- Menambah strategy baru (misal: `FlashLightStrategy`) tidak mengubah `ObstacleAnalyzer`.
|
||||
- Setiap strategy mudah di-unit-test secara terisolasi.
|
||||
|
||||
---
|
||||
|
||||
### 7. Chain of Responsibility Pattern
|
||||
|
||||
**Lokasi (Flutter):** `core/network/api_client.dart` (Dio interceptors)
|
||||
**Lokasi (Backend):** `security/JwtAuthFilter.java` + Spring Security
|
||||
|
||||
#### Masalah yang Diselesaikan
|
||||
|
||||
Setiap HTTP request perlu melewati beberapa proses: menambahkan JWT token, menangani error secara terpusat, dan mencatat log untuk debugging. Di backend, setiap request harus divalidasi JWT sebelum mencapai controller. Tanpa pola ini, logika ini akan tersebar dan terduplikasi di mana-mana.
|
||||
|
||||
#### Solusi
|
||||
|
||||
**Flutter:** Dio interceptor chain memproses setiap request secara berurutan — `AuthInterceptor → ErrorInterceptor → LogInterceptor`. Setiap interceptor bisa memproses, memodifikasi, atau memblokir request, lalu meneruskan ke handler berikutnya.
|
||||
|
||||
**Backend:** Spring Security filter chain — `JwtAuthFilter → SecurityConfig → Controller` — memvalidasi setiap request sebelum mencapai endpoint bisnis.
|
||||
|
||||
#### Implementasi
|
||||
|
||||
```dart
|
||||
// core/network/api_client.dart
|
||||
class ApiClient {
|
||||
late final Dio _dio;
|
||||
|
||||
ApiClient() {
|
||||
_dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
));
|
||||
|
||||
// ─── Chain: urutan eksekusi diatur oleh urutan penambahan ────
|
||||
_dio.interceptors.addAll([
|
||||
AuthInterceptor(_dio), // [0] attach token, refresh jika expired
|
||||
ErrorInterceptor(), // [1] map HTTP error → domain Failure
|
||||
LogInterceptor( // [2] print request/response
|
||||
requestBody: true,
|
||||
responseBody: kDebugMode,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// core/network/interceptors/auth_interceptor.dart
|
||||
class AuthInterceptor extends QueuedInterceptorsWrapper {
|
||||
final Dio _dio;
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
// ─── Attach Bearer token ke setiap request ───────────────
|
||||
final token = await _storage.read(key: 'access_token');
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options); // teruskan ke interceptor berikutnya
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (err.response?.statusCode == 401) {
|
||||
// ─── Token expired: refresh otomatis lalu retry ──────────
|
||||
try {
|
||||
final refreshToken = await _storage.read(key: 'refresh_token');
|
||||
final response = await _dio.post('/auth/refresh',
|
||||
data: {'refreshToken': refreshToken});
|
||||
final newToken = response.data['data']['accessToken'];
|
||||
await _storage.write(key: 'access_token', value: newToken);
|
||||
|
||||
// Retry request asli dengan token baru
|
||||
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
|
||||
final retried = await _dio.fetch(err.requestOptions);
|
||||
handler.resolve(retried);
|
||||
return;
|
||||
} catch (_) {
|
||||
// Refresh juga gagal → paksa logout
|
||||
GetIt.I<AuthBloc>().add(LogoutRequested());
|
||||
}
|
||||
}
|
||||
handler.next(err); // teruskan ke ErrorInterceptor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```dart
|
||||
// core/network/interceptors/error_interceptor.dart
|
||||
class ErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
// ─── Map HTTP error code → domain Failure object ──────────
|
||||
final failure = switch (err.response?.statusCode) {
|
||||
400 => ValidationFailure(err.response?.data['message']),
|
||||
401 => UnauthorizedFailure(),
|
||||
403 => ForbiddenFailure(),
|
||||
404 => NotFoundFailure(),
|
||||
500 => ServerFailure('Server error, please try again'),
|
||||
_ => NetworkFailure('No internet connection'),
|
||||
};
|
||||
handler.next(err.copyWith(error: failure));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// security/JwtAuthFilter.java — Backend chain handler
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
private final UserDetailsService userDetailsService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain // ← chain berikutnya
|
||||
) throws ServletException, IOException {
|
||||
|
||||
final String authHeader = request.getHeader("Authorization");
|
||||
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
filterChain.doFilter(request, response); // lewati jika tidak ada token
|
||||
return;
|
||||
}
|
||||
|
||||
final String jwt = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
final String email = jwtUtil.extractEmail(jwt);
|
||||
final UserDetails userDetails = userDetailsService.loadUserByUsername(email);
|
||||
|
||||
if (jwtUtil.isTokenValid(jwt, userDetails)) {
|
||||
// ─── Set authentication context ──────────────────────
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
}
|
||||
} catch (JwtException e) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return; // blokir — tidak diteruskan ke controller
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response); // teruskan ke SecurityConfig
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Diagram
|
||||
|
||||
```
|
||||
HTTP Request dari Flutter
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ AuthInterceptor │ → attach Bearer token
|
||||
│ [Flutter Dio] │ → auto-refresh jika 401
|
||||
└────────┬──────────┘
|
||||
│ handler.next()
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ ErrorInterceptor │ → map error → domain Failure
|
||||
└────────┬──────────┘
|
||||
│ handler.next()
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ LogInterceptor │ → print untuk debugging
|
||||
└────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
Backend REST API
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ JwtAuthFilter │ → extract & validasi JWT
|
||||
│ [Spring Security] │ → set SecurityContext
|
||||
└────────┬──────────┘
|
||||
│ filterChain.doFilter()
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ SecurityConfig │ → cek role: GUARDIAN vs USER
|
||||
└────────┬──────────┘
|
||||
│ (jika authorized)
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Controller │ → proses bisnis logic
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
#### Keuntungan dalam WalkGuide
|
||||
|
||||
- Token refresh otomatis — user tidak perlu login ulang saat token expired.
|
||||
- Error handling terpusat — tidak ada `try-catch` duplikat di setiap repository.
|
||||
- Menambah interceptor baru (misal: rate limiting, analytics) cukup ditambah ke chain.
|
||||
- Backend: satu filter untuk semua 26 endpoint, tidak perlu validasi JWT di setiap controller.
|
||||
|
||||
---
|
||||
|
||||
## Ringkasan Matriks
|
||||
|
||||
| # | Pattern | Kategori | Problem Solved | File Utama |
|
||||
|---|---------|----------|---------------|------------|
|
||||
| 1 | **Builder** | Creational | Konstruksi objek kompleks dengan banyak optional field | `User.java`, `FcmService.java` |
|
||||
| 2 | **Singleton** | Creational | Satu instance untuk heavy resource (model AI, TTS, WebSocket) | `injection_container.dart` |
|
||||
| 3 | **Facade** | Structural | Sembunyikan kompleksitas koordinasi multi-service | `voice_command_handler.dart`, `GuardianDashboardService.java` |
|
||||
| 4 | **Repository (Proxy)** | Structural | Offline-first — domain layer tidak tahu sumber data | `*_repository_impl.dart` |
|
||||
| 5 | **Observer** | Behavioral | Reaktif terhadap state change tanpa coupling ketat | `walk_guide_bloc.dart`, `websocket_service.dart` |
|
||||
| 6 | **Strategy** | Behavioral | Alert mode bisa diganti runtime tanpa ubah core logic | `obstacle_analyzer.dart`, `ObstacleAlertStrategyService.java` |
|
||||
| 7 | **Chain of Responsibility** | Behavioral | Pipeline request: auth → error → log (Flutter & Backend) | `api_client.dart`, `JwtAuthFilter.java` |
|
||||
|
||||
> **Catatan exam:** 7 patterns (≥4 ✅), mencakup 3 kategori GoF (≥1 per kategori ✅): Creational, Structural, Behavioral.
|
||||
Loading…
x
Reference in New Issue
Block a user