docs: add design patterns documentation for WalkGuide

This commit is contained in:
5803024019 2026-05-20 09:02:21 +07:00
parent 697d011d23
commit c862d4eaeb
2 changed files with 1493 additions and 0 deletions

View 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

View 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.