36 KiB
Design Patterns — WalkGuide
Flutter × Spring Boot × In-Device AI Dokumentasi 7 Design Patterns (GoF) · Wajib ≥4, min. 1 per kategori
Daftar Isi
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
// 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;
}
// 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();
}
// 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 punyauniqueUserId). - Firebase
Messagepayload 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
// 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(),
),
);
}
// 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
// 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
}
}
}
// 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/dashboardmengembalikan 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
// 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});
}
// 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;
}
}
// 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
// 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));
}
}
// 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(),
),
],
);
}
}
// 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_testpackage 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
// 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);
}
}
// 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);
}
}
// 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-configtanpa restart app. - Menambah strategy baru (misal:
FlashLightStrategy) tidak mengubahObstacleAnalyzer. - 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
// 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,
),
]);
}
}
// 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
}
}
// 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));
}
}
// 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-catchduplikat 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.