Compare commits

..
26 Commits
Author SHA1 Message Date
Wowieee4 d2b3534dde POLISHING STUFF and Smarter AI, and alot i mean.. ALOT OF BUG FIX AND OPTIMIZE 2026-05-29 15:50:51 +07:00
5803024004 1110e5a42d Update lot features 2026-05-28 16:29:23 +07:00
Wowieee4 6272ece15d im so tired man 2026-05-28 11:27:06 +07:00
Wowieee4 66da2473e1 Merge remote-tracking branch 'origin/Update-UI-+-Agora-Call-Guardian-User' 2026-05-27 22:35:00 +07:00
Wowieee4 c6d1e01023 Update README to match current codebase 2026-05-27 22:19:45 +07:00
5803024004 3cb32a4d69 Update UI + Agora Call 2026-05-27 19:36:01 +07:00
Wowieee4 a629357e8c Fix local dev config and ALOTTTT OFF FLUTTER, im tired boss.. 2026-05-26 21:09:13 +07:00
Wowieee4 b8ad8df993 ADD ALOT OF POLISHING AND UPDATES... 2026-05-26 05:52:12 +07:00
5803024019 2e3ecdf1d1 Merge pull request 'main' (#6) from 5803024019/Final-08-Evan-Jap-Bambang-WalkGuide-AI:main into main
Reviewed-on: 2526-mobile-programming/Final-08-Evan-Jap-Bambang-WalkGuide-AI#6
2026-05-23 23:42:07 -04:00
5803024019 f844ddddbb docs: update final report title and group member details 2026-05-24 10:40:26 +07:00
5803024019 6ca89ca896 feat: integrate YOLO runtime, update screens, and sync docs 2026-05-24 10:26:58 +07:00
5803024019 2194833509 Merge pull request 'main' (#5) from 5803024019/Final-08-Evan-Jap-Bambang-WalkGuide-AI:main into main
Reviewed-on: 2526-mobile-programming/Final-08-Evan-Jap-Bambang-WalkGuide-AI#5
2026-05-20 22:58:09 -04:00
5803024019 aa3dc4560e docs(architecture): update Facade diagram with Guardian dashboard controller 2026-05-21 09:57:28 +07:00
5803024019 2abdf012d6 docs(architecture): update Facade diagram with Guardian dashboard controller 2026-05-21 09:55:34 +07:00
5803024019 a0535ae12f Merge pull request 'docs(architecture): add design pattern UML diagrams' (#4) from 5803024019/Final-08-Evan-Jap-Bambang-WalkGuide-AI:main into main
Reviewed-on: 2526-mobile-programming/Final-08-Evan-Jap-Bambang-WalkGuide-AI#4
2026-05-20 22:14:58 -04:00
5803024019 5865951153 docs(architecture): add design pattern UML diagrams 2026-05-21 09:11:49 +07:00
5803024019 98724af6a9 Merge pull request 'main' (#3) from 5803024019/Final-08-Evan-Jap-Bambang-WalkGuide-AI:main into main
Reviewed-on: 2526-mobile-programming/Final-08-Evan-Jap-Bambang-WalkGuide-AI#3
2026-05-19 23:31:06 -04:00
5803024019 31df3bd590 Merge branch 'main' of https://git-eng.ukwms.ac.id/5803024019/Final-08-Evan-Jap-Bambang-WalkGuide-AI 2026-05-20 09:02:24 +07:00
5803024019 c862d4eaeb docs: add design patterns documentation for WalkGuide 2026-05-20 09:02:21 +07:00
5803024019 f228fb1708 merge upstream 2026-05-19 07:16:26 -04:00
5803024019 333590b1e7 ignore 2026-05-19 18:15:33 +07:00
5803024019 697d011d23 merge upstream 2026-05-19 07:00:03 -04:00
5803024019 e8eb8d892e Update .gitignore 2026-05-19 06:59:28 -04:00
5803024019 3d5740c8e7 merge upstream 2026-05-19 06:57:31 -04:00
5803024019 4813b745bc Merge pull request 'gitignore' (#2) from 5803024019/Final-08-Evan-Jap-Bambang-WalkGuide-AI:main into main
Reviewed-on: 2526-mobile-programming/Final-08-Evan-Jap-Bambang-WalkGuide-AI#2
2026-05-19 06:37:19 -04:00
5803024019 9ab8363041 Merge pull request 'main' (#1) from 5803024019/Final-08-Evan-Jap-Bambang-WalkGuide-AI:main into main
Reviewed-on: 2526-mobile-programming/Final-08-Evan-Jap-Bambang-WalkGuide-AI#1
2026-05-19 06:23:02 -04:00
289 changed files with 20841 additions and 36994 deletions
+16
View File
@@ -40,9 +40,25 @@ build/
.env
*.env
walkguide-backend/demo/secrets.properties
walkguide-backend/demo/hs_err_pid*.log
walkguide-backend/demo/backend-run*.log
walkguide-backend/demo/src/main/resources/firebase/*.json
walkguide-mobile/walkguide_app/android/app/google-services*.json
walkguide-mobile/walkguide_app/ios/Runner/GoogleService-Info.plist
# Android SDK path (generated by Android Studio)
walkguide-mobile/walkguide_app/android/local.properties
# Local Python/YOLO export artifacts - do not commit
walkguide-mobile/walkguide_app/.venv*/
walkguide-mobile/walkguide_app/yolov8n.onnx
walkguide-mobile/walkguide_app/yolov8n.pt
walkguide-mobile/walkguide_app/yolov8n_saved_model/
walkguide-mobile/walkguide_app/calibration_image_sample_data_*.npy
walkguide-mobile/walkguide_app/devtools_options.yaml
walkguide-mobile/walkguide_app/android/app/src/main/java/dev/flutter/plugins/
walkguide-mobile/walkguide_app/android/app/src/main/java/io/flutter/plugins/
new_file
+409 -381
View File
@@ -1,489 +1,517 @@
<div align="center">
> ⚠️ **WORK IN PROGRESS (WIP)** ⚠️
> *This repository is currently under active development. All code, data, and structures are subject to continuous changes.*
<br>
<img src="assets/gambar.png" alt="WalkGuide Banner" width="720"/>
# WalkGuide: AI-Powered Navigation
**Integrated Mobile Application Project**
*Flutter Mobile Frontend × Spring Boot Backend × OOAD*
Integrated Mobile Application Project
Flutter Mobile Frontend x Spring Boot Backend x OOAD
### Group Members
WalkGuide is an accessibility-focused mobile system for visually impaired users and their guardians. The User app provides camera-based obstacle awareness, voice/TTS interaction, SOS, notifications, navigation, and call flows. The Guardian app provides monitoring, live location, SOS handling, remote configuration, notification sending, voice notes, and pairing management.
## Group Members
| Name | NIM | Responsibility |
|------|-----|---------------|
| Bambang Herlambang | 5803024019 | - |
| Jap Robertus | 5803024004 | - |
| Evan William | 5803024001 | Backend Engineer (Spring Boot API & Flutter) |
|---|---:|---|
| Bambang Herlambang | 5803024019 | Mobile feature support, documentation, testing |
| Jap Robertus | 5803024004 | Mobile feature support, documentation, testing |
| Evan William | 5803024001 | Backend API, Flutter integration, architecture alignment |
[![Flutter](https://img.shields.io/badge/Flutter-Clean_Architecture-02569B?style=flat-square&logo=flutter&logoColor=white)](https://flutter.dev/)
[![Spring Boot](https://img.shields.io/badge/Spring_Boot-REST_API-6DB33F?style=flat-square&logo=spring&logoColor=white)](https://spring.io/projects/spring-boot)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-Database-4169E1?style=flat-square&logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![Firebase](https://img.shields.io/badge/Firebase-FCM-FFCA28?style=flat-square&logo=firebase&logoColor=black)](https://firebase.google.com/)
[![Agora](https://img.shields.io/badge/Agora-VoIP-099DFD?style=flat-square)](https://www.agora.io/)
[![License](https://img.shields.io/badge/License-MIT-22c55e?style=flat-square)](LICENSE)
## Project Status
![Last Updated](https://img.shields.io/badge/Last_Updated-WIP-blue?style=for-the-badge)
![Author](https://img.shields.io/badge/By-evan--william-brightgreen?style=for-the-badge)
This repository is no longer a backend-only skeleton. The current codebase contains:
[**System Architecture**](#system-architecture) · [**Tech Stack**](#tech-stack) · [**Implementations**](#implementations) · [**API Endpoints**](#api-endpoints) · [**Design Patterns**](#design-patterns) · [**Results**](#results) · [**Weekly Progress**](#weekly-progress)
- Spring Boot backend with JWT/RBAC, Flyway migrations V1-V17, PostgreSQL, OpenAPI, WebSocket, FCM service hooks, Agora token/call notification flow, SOS acknowledge/resolve flow, pairing code flow, service/controller tests, Testcontainers setup, and k6 assets.
- Flutter app with server connection screen, auth, role-based routing, User screens, Guardian screens, feature page UI shell, friendly error handling, offline queue/cache layer, voice note UI support, SOS handling UI, WalkGuide/YOLO support files, and Android/mobile-first dependencies.
- OOAD documentation in `ooad-docs/`, including the 7 GoF design pattern PUML diagrams and traceability documentation.
</div>
Primary demo target: Android APK connected to the Spring Boot backend.
Chrome/web can be used for UI/debug flows, but camera, native AI, SQLite FFI, and mobile permissions are Android-first.
---
## Overview
## Overview — WalkGuide System
Core objective: build an accessible navigation assistant that can help visually impaired users move more safely while allowing a Guardian to monitor, configure, and respond to events in real time.
**Core Objective:** How can we build an ultra-low latency, accessible navigation system for visually impaired users while providing real-time oversight for their guardians?
Important flows implemented or represented in the codebase:
This project implements a dual-interface mobile application. The system relies on **On-Device AI (TFLite / YOLOv8n)** to eliminate network latency during obstacle detection, paired with a robust **Spring Boot backend** for secure authentication, guardian-user pairing, real-time location tracking via WebSocket, and push notifications via Firebase FCM.
**Deployment:** Backend is deployed on a university server at `202.46.28.160`. The Flutter APK uses a dynamic server URL — no hardcoded addresses — allowing multi-device testing without rebuilding.
---
- Register/Login with Guardian and User roles.
- Pairing by generated pairing code / user identity flow.
- Guardian dashboard and tools screens.
- User SOS trigger, Guardian acknowledge and resolve.
- Guardian text and voice-note style notification sending.
- Notification read/read-all handling.
- Location update and Guardian live map support.
- AI configuration, voice command configuration, hardware shortcut configuration.
- WalkGuide obstacle detection pipeline integration points.
- Call token and call notification endpoints for Agora-style VoIP flow.
## System Architecture
The study follows a strict three-pillar enterprise structure:
The project follows a feature-first architecture across backend, mobile, and OOAD documents.
**Pillar 1 — OOAD (Object-Oriented Analysis & Design):** Comprehensive modeling using Use Case, Class, Sequence, and ERD diagrams. The codebase strictly implements ≥ 7 GoF Design Patterns (Builder, Singleton, Facade, Repository/Proxy, Observer, Strategy, Chain of Responsibility).
### Backend
**Pillar 2 — Flutter (Mobile Frontend):** Implements Clean Architecture (Domain, Data, Presentation layers) with BLoC for state management. Uses `Dio` with interceptors for secure HTTP communication. Server URL is dynamically configured via `SharedPreferences` on first launch.
Backend follows layered Spring Boot architecture:
**Pillar 3 — Spring Boot (Backend API):** A layered architecture (Controller → Service → Repository) powered by Java 21. Features JWT-based Role-Based Access Control (RBAC), standardized `ApiResponse` envelopes, WebSocket (STOMP) for real-time data, and Firebase Admin SDK for push notifications.
```text
Controller -> Service -> Repository -> Entity -> PostgreSQL
```
---
Main backend concerns:
- `controller/`: REST API endpoints.
- `service/`: application/business logic.
- `repository/`: Spring Data JPA persistence contracts.
- `entity/`: database-mapped entities.
- `dto/request` and `dto/response`: API input/output contracts.
- `security/`: JWT utility, JWT filter, custom user details service.
- `config/`: Security, WebSocket, OpenAPI, Firebase/FCM, seeding.
- `websocket/`: STOMP broadcasting helper.
- `db/migration/`: Flyway schema migrations.
### Flutter
Flutter uses a feature-first layout. Several critical features now have domain/data/application/presentation structure, while compatibility wrapper screens remain for the existing app routes.
Main Flutter concerns:
- `app/`: app shell, router, dependency injection.
- `core/`: API service/client, services, storage/cache, AI helpers, errors.
- `features/`: auth, server connect, pairing, SOS, notifications, WalkGuide, activity log, navigation, settings, call, guardian dashboard/tools, manual, benchmark.
- `shared/widgets/`: common UI shell and reusable feature page components.
### OOAD
The `ooad-docs/` folder contains traceability and diagrams, including:
- `01_Builder_Pattern.puml`
- `02_Singleton_Pattern.puml`
- `03_Facade_Pattern.puml`
- `04_Repository_Proxy_Pattern.puml`
- `05_Observer_Pattern.puml`
- `06_Strategy_Pattern.puml`
- `07_ChainOfResponsibility_Pattern.puml`
- Use case, sequence, state, ERD, class, and component diagrams under `ooad-docs/diagrams/`.
## Tech Stack
### Backend (Spring Boot)
### Backend
| Library / Tool | Version | Purpose |
| Tool / Library | Current Codebase | Purpose |
|---|---|---|
| Java | 21 | Primary language |
| Spring Boot | 3.3.x | Main framework |
| Spring Security | (bundled) | Auth + RBAC |
| Spring Data JPA | (bundled) | ORM |
| Spring WebSocket (STOMP) | (bundled) | Real-time location & notification push |
| PostgreSQL Driver | (bundled) | DB connection — university server `202.46.28.160` |
| Flyway | 10.x | Database schema migration |
| JJWT | 0.11.5 | JWT access + refresh token |
| Firebase Admin SDK | 9.x | FCM push notifications |
| Agora RESTful API | - | Generate Agora RTC token for VoIP |
| Springdoc OpenAPI | 2.3.0 | Swagger UI documentation |
| Lombok | latest | Boilerplate reduction |
| JUnit 5 + Mockito | (bundled) | Unit testing |
| MockMvc + Testcontainers | 1.19.x | Integration testing with real PostgreSQL |
| JaCoCo | 0.8.x | Code coverage (target ≥ 70%) |
| Java | 21 | Backend language |
| Spring Boot | 3.2.5 | Main backend framework |
| Spring Security | Spring Boot managed | JWT auth and RBAC |
| Spring Data JPA | Spring Boot managed | ORM and repositories |
| Spring WebSocket | Spring Boot managed | STOMP realtime channels |
| PostgreSQL Driver | Runtime dependency | University PostgreSQL database |
| Flyway | Core + PostgreSQL module | Database migrations |
| JJWT | 0.11.5 | JWT access token handling |
| Springdoc OpenAPI | 2.3.0 | Swagger/OpenAPI docs |
| Lombok | 1.18.36 | Builders and boilerplate reduction |
| JUnit 5 / Mockito / MockMvc | Test dependencies | Unit and controller testing |
| Testcontainers | 1.19.7 | PostgreSQL-backed integration tests |
| JaCoCo | 0.8.11 | Coverage report |
### Flutter (Mobile)
### Flutter
| Package | Version | Purpose |
| Package | Current Codebase | Purpose |
|---|---|---|
| flutter_bloc | 8.x | State management (sole pattern) |
| go_router | 14.x | Navigation + role-based route guards |
| dio | 5.x | HTTP client with interceptors |
| shared_preferences | 2.x | Persist dynamic server URL |
| flutter_secure_storage | 10.x | Secure JWT token storage |
| drift | 2.x | SQLite ORM for offline cache |
| tflite_flutter | 0.10.x | Run YOLOv8n on-device |
| camera | 0.10.x | Camera feed for YOLO inference |
| flutter_tts | 4.x | Text-to-Speech (ID + EN) |
| speech_to_text | 6.x | Always-listening voice commands |
| agora_rtc_engine | 6.x | VoIP call Guardian ↔ User |
| firebase_messaging | 14.x | FCM push notification receiver |
| flutter_map | 6.x | OpenStreetMap (free, no API key) |
| geolocator | 11.x | Real-time GPS |
| stomp_dart_client | 2.x | WebSocket STOMP for live tracking |
| get_it | 7.x | Service locator / dependency injection |
| dartz | 0.10.x | `Either<Failure, Data>` typed error handling |
| vibration | 1.x | Haptic feedback on obstacle detection |
| flutter_bloc | 8.1.6 | Cubit/BLoC state management |
| go_router | 14.2.7 | App routing |
| dio | 5.4.3+1 | REST client |
| flutter_secure_storage | 9.2.2 | Secure token storage on mobile |
| shared_preferences | 2.3.2 | Server URL and web cache fallback |
| drift / sqlite3 | drift 2.18.0, sqlite3 2.4.7 | Local persistence support |
| sqlite3_flutter_libs | 0.5.24 | Android/iOS SQLite native libs |
| camera | 0.11.0+2 | Camera feed |
| tflite_flutter | 0.12.1 | On-device model inference |
| flutter_tts | 4.0.2 | Text-to-speech |
| speech_to_text | 7.0.0 | Voice recognition support |
| firebase_core / firebase_messaging | 3.3.0 / 15.1.0 | FCM integration |
| flutter_local_notifications | 17.2.1+2 | Foreground/local notification UI |
| flutter_map / latlong2 | 7.0.2 / 0.9.1 | OpenStreetMap UI |
| geolocator | 12.0.0 | GPS/location |
| agora_rtc_engine | 6.3.2 | VoIP engine integration |
| just_audio / record | 0.9.40 / 5.1.2 | Voice note playback/recording |
| get_it | 8.0.2 | Dependency injection |
| dartz | 0.10.1 | Either-style error handling |
| connectivity_plus | 6.0.3 | Offline/online detection |
### External Services
## Runtime Configuration
| Service | Purpose | Cost |
|---|---|---|
| Firebase (FCM) | Push notifications | Free |
| Agora RTC | In-app VoIP audio call | 10,000 min/month free |
| University Server PostgreSQL | DB at `202.46.28.160:2002` | Free (managed by lecturer) |
| OpenStreetMap + OSRM | Map tiles + turn-by-turn routing | Free |
| YOLOv8n (Ultralytics) | Obstacle detection model (.tflite) | Free (open source) |
### Backend
---
Local/dev config imports an optional gitignored file at `walkguide-backend/demo/secrets.properties`.
Copy `walkguide-backend/demo/secrets.properties.example` to `secrets.properties` and fill it locally.
Tracked config files do not contain DB passwords, JWT secrets, Agora certificates, or Firebase keys.
Both dev and production expect these values from environment variables or `secrets.properties`:
```text
DB_URL
DB_USERNAME
DB_PASSWORD
JWT_SECRET
AGORA_APP_ID
AGORA_APP_CERTIFICATE
```
### Flutter
The Flutter app uses a dynamic server URL. On first launch, use the Server Connect screen and enter a backend URL such as:
```text
http://202.46.28.170:8080
```
For testing on a physical phone against a backend running on a laptop, do not use `localhost`. Use the laptop LAN IP:
```text
http://192.168.x.x:8080
```
## Implementations
### Design A — User Mode (Visually Impaired)
An accessibility-first interface that opens directly to an active camera view.
- **On-Device AI:** YOLOv8n obstacle detection via TFLite runs locally — zero network latency. Detects 80 COCO object classes, reports direction (LEFT/CENTER/RIGHT) and estimated distance.
- **Voice Commands:** 14 configurable voice commands (e.g., "Start Walkguide", "Call Guardian", "Send SOS", "Where Am I"). Always-listening `SpeechToText` with auto-restart.
- **Hardware Mapping:** Physical volume buttons mapped to critical actions (Vol Up = Call Guardian, Vol Down = Start WalkGuide by default).
- **TTS Feedback:** Queued and immediate Text-to-Speech for obstacle alerts, navigation, and screen announcements. Supports Bahasa Indonesia and English.
- **SOS System:** One-command emergency alert triggers high-priority FCM to Guardian with GPS coordinates.
### User Mode
### Design B — Guardian Mode (Admin/Caregiver)
A command center dashboard for oversight and remote configuration.
- **Real-time Live Map:** Tracks User's GPS location via WebSocket STOMP subscription, rendered on OpenStreetMap with `flutter_map`. Includes geofence circle overlay.
- **Remote AI Configuration:** Adjusts YOLO confidence threshold, alert distance thresholds, max inference FPS, and enabled object labels.
- **Voice Command Management:** Edits trigger phrases and enables/disables any of the 14 voice commands remotely.
- **Notifications:** Sends text messages or voice notes (recorded audio) directly to the User's device.
- **Geofence:** Sets a geographic boundary — backend notifies Guardian via FCM when User exits.
- WalkGuide camera screen and AI pipeline integration points.
- TTS feedback and friendly error handling.
- SOS screen with emergency action flow.
- Notifications screen with read/read-all actions.
- Activity log, navigation, settings, pairing, and manual screens.
- Voice command and shortcut configuration retrieval paths.
- Offline queue/cache support for core app data.
---
### Guardian Mode
- Guardian dashboard and tools.
- Guardian live map screen.
- Guardian activity log screen.
- Send notification screen with text and voice note modes.
- AI config screen.
- Voice command and shortcut management screens.
- Geofence/settings screens.
- SOS acknowledge and resolve support.
### Backend API
- Auth, pairing, user, guardian, and shared call controllers.
- Service layer for pairing, location, activity, obstacles, notifications, SOS, AI config, voice commands, hardware shortcuts, geofence, user settings, call notifications, and dashboard aggregation.
- WebSocket broadcaster for location/SOS/notification-style real-time updates.
- Flyway-managed PostgreSQL schema.
## Database Schema
Database runs on PostgreSQL at `202.46.28.160:2002`. Schema is managed exclusively by Flyway migrations. `spring.jpa.hibernate.ddl-auto=validate`.
Database: PostgreSQL on the university server.
Schema management: Flyway.
Hibernate mode: `validate`.
| Migration | Table | Status |
Current migrations in `walkguide-backend/demo/src/main/resources/db/migration/`:
| Migration | File | Status |
|---|---|---|
| V1 | `users` | ✅ Exists |
| V2 | Seed data | ✅ Exists |
| V3 | Guardian-User link | ✅ Exists |
| V4 | Add `unique_user_id` column | 🔄 Needs creation |
| V5 | `pairing_relations` | 🔄 Needs creation |
| V6 | `activity_logs` | 🔄 Needs creation |
| V7 | `obstacle_logs` | 🔄 Needs creation |
| V8 | `location_history` | 🔄 Needs creation |
| V9 | `guardian_notifications` | 🔄 Needs creation |
| V10 | `sos_events` | 🔄 Needs creation |
| V11 | `user_settings` | 🔄 Needs creation |
| V12 | `ai_configs` | 🔄 Needs creation |
| V13 | `voice_command_configs` | 🔄 Needs creation |
| V14 | `hardware_shortcuts` | 🔄 Needs creation |
| V15 | `geofence_configs` | 🔄 Needs creation |
| V16 | `refresh_tokens` | 🔄 Needs creation |
---
| V1 | `V1__create_users_table.sql` | Present |
| V2 | `V2__seed_users.sql` | Present |
| V3 | `V3__link_guardian_user.sql` | Present |
| V4 | `V4__alter_users_add_columns.sql` | Present |
| V5 | `V5__create_pairing_relations.sql` | Present |
| V6 | `V6__create_activity_logs.sql` | Present |
| V7 | `V7__create_obstacle_logs.sql` | Present |
| V8 | `V8__create_location_history.sql` | Present |
| V9 | `V9__create_guardian_notifications.sql` | Present |
| V10 | `V10__create_sos_events.sql` | Present |
| V11 | `V11__create_user_settings.sql` | Present |
| V12 | `V12__create_ai_configs.sql` | Present |
| V13 | `V13__create_voice_command_configs.sql` | Present |
| V14 | `V14__create_hardware_shortcuts.sql` | Present |
| V15 | `V15__create_geofence_configs.sql` | Present |
| V16 | `V16__create_refresh_tokens.sql` | Present |
| V17 | `V17__add_expiring_pairing_codes.sql` | Present |
## API Endpoints
26 REST endpoints across 5 groups (exam minimum: 10).
**Auth** — `/api/v1/auth`
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/ping` | None | Health check / server connection test |
| POST | `/register` | None | Register Guardian or User |
| POST | `/login` | None | Login, returns access + refresh token |
| POST | `/refresh` | None | Refresh access token |
| POST | `/logout` | ✅ | Logout, invalidate refresh token |
| PUT | `/fcm-token` | ✅ | Update FCM device token |
**Pairing** — `/api/v1/shared/pairing`
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | `/invite` | GUARDIAN | Invite User by 12-char unique ID |
| POST | `/respond` | USER | Accept or reject pairing invite |
| DELETE | `/unpair` | ✅ | Dissolve active pairing |
| GET | `/status` | ✅ | Get current pairing status |
**Guardian** — `/api/v1/guardian`
### Auth - `/api/v1/auth`
| Method | Path | Description |
|---|---|---|
| GET | `/dashboard` | Combined home data (user status, recent logs, SOS count) |
| GET | `/user-status` | Full status of paired User |
| GET | `/user-location` | Last known GPS location |
| GET | `/ping` | Server connection check |
| POST | `/register` | Register Guardian or User |
| POST | `/login` | Login |
| POST | `/refresh` | Refresh access token |
| POST | `/logout` | Logout |
| PUT | `/fcm-token` | Update FCM token |
### Pairing - `/api/v1/shared/pairing`
| Method | Path | Description |
|---|---|---|
| GET | `/code` | Get current pairing code |
| POST | `/code/regenerate` | Regenerate pairing code |
| POST | `/invite` | Guardian invites User |
| POST | `/respond` | User accepts/rejects invite |
| DELETE | `/unpair` | Remove pairing |
| GET | `/status` | Get current pairing status |
### Guardian - `/api/v1/guardian`
| Method | Path | Description |
|---|---|---|
| GET | `/dashboard` | Guardian home data |
| GET | `/user-location` | Last known User location |
| GET | `/location-history` | Paginated location history |
| GET | `/activity-logs` | Paginated activity logs of paired User |
| GET | `/obstacle-logs` | Paginated obstacle detection logs |
| POST | `/notifications/send` | Send text or voice note to User |
| GET | `/activity-logs` | Paginated User activity logs |
| GET | `/obstacle-logs` | Paginated obstacle logs |
| POST | `/notifications/send` | Send text or voice note notification |
| GET | `/sos-events` | Paginated SOS events |
| PUT | `/sos/{id}/acknowledge` | Acknowledge SOS alert |
| GET/PUT | `/ai-config` | Get or update AI configuration |
| GET/PUT | `/voice-commands` | Get or update voice command configs |
| GET/PUT | `/shortcuts` | Get or update hardware shortcut configs |
| GET/PUT | `/geofence` | Get or update geofence config |
| PUT | `/sos/{id}/acknowledge` | Mark SOS as acknowledged |
| PUT | `/sos/{id}/resolve` | Mark SOS as resolved/handled |
| GET/PUT | `/ai-config` | Get/update AI config |
| GET/PUT | `/voice-commands` | Get/update voice command config |
| GET/PUT | `/shortcuts` | Get/update shortcut config |
| GET/PUT | `/geofence` | Get/update geofence config |
| GET/PUT | `/user-settings` | Get/update paired User settings |
**User** — `/api/v1/user`
### User - `/api/v1/user`
| Method | Path | Description |
|---|---|---|
| GET | `/profile` | User profile (id, email, displayName, uniqueUserId) |
| GET/PUT | `/settings` | Get or update TTS/haptic settings |
| GET | `/voice-commands` | Get all voice commands (read-only) |
| GET/PUT | `/shortcuts` | Get or capture hardware shortcut assignments |
| GET | `/ai-config` | Get AI config (read-only) |
| POST | `/location` | Send GPS update |
| POST | `/obstacle` | Log detected obstacle |
| POST | `/sos` | Trigger SOS alert |
| GET | `/activity-logs` | Paginated own activity log |
| GET | `/notifications` | Paginated notifications from Guardian |
| GET | `/notifications/unread-count` | Unread notification count |
| PUT | `/notifications/mark-all-read` | Mark all as read |
| PUT | `/notifications/{id}/read` | Mark one as read |
| POST | `/walkguide/start` | Log WalkGuide session start |
| POST | `/walkguide/stop` | Log WalkGuide session stop |
| GET | `/profile` | Current User profile |
| GET/PUT | `/settings` | Get/update User settings |
| GET | `/voice-commands` | Get voice command config |
| GET/PUT | `/shortcuts` | Get/update shortcut config |
| GET | `/ai-config` | Get AI config |
| POST | `/location` | Send location update |
| POST | `/obstacle` | Log obstacle |
| POST | `/sos` | Trigger SOS |
| GET | `/sos-events` | Get own SOS events |
| GET | `/activity-logs` | Get activity logs |
| GET | `/notifications` | Get notifications |
| GET | `/notifications/unread-count` | Get unread count |
| PUT | `/notifications/mark-all-read` | Mark all notifications read |
| PUT | `/notifications/{id}/read` | Mark one notification read |
| POST | `/walkguide/start` | Log WalkGuide start |
| POST | `/walkguide/stop` | Log WalkGuide stop |
**Shared Call** — `/api/v1/shared/call`
### Shared Call - `/api/v1/shared/call`
| Method | Path | Description |
|---|---|---|
| POST | `/token` | Generate Agora RTC token |
| POST | `/notify` | Send "Incoming Call" FCM to other party |
---
| POST | `/token` | Generate call token/channel payload |
| POST | `/notify` | Notify other party of incoming call |
| POST | `/end` | Notify/end call session |
## Design Patterns
7 GoF Design Patterns implemented (exam minimum: 4, minimum 1 per category).
The project documents and maps 7 GoF-related patterns in `ooad-docs/`.
| # | Category | Pattern | Location |
| # | Category | Pattern | Main Location |
|---|---|---|---|
| 1 | Creational | **Builder** | `User.java` (`@Builder`), `FcmService` message construction |
| 2 | Creational | **Singleton** | Flutter: `TtsService`, `YoloDetector`, `WebSocketService`, `AgoraService` via GetIt |
| 3 | Structural | **Facade** | Flutter: `VoiceCommandHandler`; Backend: `GuardianDashboardService` |
| 4 | Structural | **Repository (Proxy)** | All `*_repository_impl.dart` — proxy between domain and data sources |
| 5 | Behavioral | **Observer** | BLoC pattern (BLoC = Subject, Widgets = Observers); WebSocket callbacks |
| 6 | Behavioral | **Strategy** | `ObstacleAnalyzer` direction strategy; `ObstacleAlertStrategyService` (backend) |
| 7 | Behavioral | **Chain of Responsibility** | Spring Security filter chain; Dio interceptor chain |
| 1 | Creational | Builder | Backend entities/DTO construction, Lombok builders |
| 2 | Creational | Singleton | Flutter services registered through GetIt |
| 3 | Structural | Facade | Guardian dashboard aggregation and voice command/service coordination |
| 4 | Structural | Repository / Proxy | Spring Data repositories and Flutter repository-style data access |
| 5 | Behavioral | Observer | BLoC/Cubit state listeners and WebSocket callbacks |
| 6 | Behavioral | Strategy | Obstacle analysis / alert behavior mapping documented in OOAD |
| 7 | Behavioral | Chain of Responsibility | Spring Security filter chain and Dio interceptors |
---
See:
## Metrics
- `ooad-docs/DESIGN_PATTERNS.md`
- `ooad-docs/TRACEABILITY_AUDIT.md`
- `ooad-docs/01_Builder_Pattern.puml` through `ooad-docs/07_ChainOfResponsibility_Pattern.puml`
| Metric | Instrument | Target |
|---|---|---|
| API Throughput | Apache JMeter / k6 | ≥ 100 req/s under load |
| API p95 Latency | Apache JMeter / k6 | < 500ms |
| UI Frame Rate | Flutter DevTools | ≥ 90% frames < 16ms |
| Code Coverage | JaCoCo | ≥ 70% (Service & Controller) |
| APK Size | `flutter build apk --analyze-size` | < 50MB |
## Metrics And Evidence
---
The repository includes testing and benchmark support, but final scoring evidence should be generated on the target machine/device before submission.
| Area | Current Support |
|---|---|
| Backend unit tests | JUnit/Mockito tests under `src/test/java` |
| Backend controller/integration tests | MockMvc and Testcontainers setup present |
| Coverage | JaCoCo configured in Maven |
| Load testing | k6 assets/results folder present under backend project |
| Flutter tests | Unit/widget/integration test files present |
| Flutter performance | Benchmark evidence template in `ooad-docs/BENCHMARK_EVIDENCE_TEMPLATE.md` |
Recommended final evidence:
- `mvn test` or `mvn verify` output.
- JaCoCo HTML report.
- Testcontainers run on a Docker-enabled machine.
- k6 result at 50 or more concurrent virtual users.
- Flutter physical Android profile evidence: cold start, memory, jank/frame timing, CPU during AI, API latency, APK size.
## Repository Structure
> 🚨 **DISCLAIMER:** This repository is in active development. The directory tree below reflects the intended final architecture. Flutter implementation is currently pending — backend (Spring Boot) is the active development phase.
```text
/
├── walkguide-backend/ # [Spring Boot Engine — ACTIVE]
│ ├── src/main/java/com/walkguide/
│ │ ├── config/ # SecurityConfig, WebSocketConfig, OpenApiConfig, FcmConfig, DataSeeder
│ │ ├── enums/ # UserRole, PairingStatus, ActivityLogType, VoiceCommandKey,
│ │ │ # HardwareShortcutKey, NotificationType, SosStatus
│ │ ├── entity/ # User, PairingRelation, ActivityLog, ObstacleLog,
│ │ │ # LocationHistory, GuardianNotification, SosEvent,
│ │ │ # UserSettings, AiConfig, VoiceCommandConfig,
│ │ │ # HardwareShortcut, GeofenceConfig, RefreshToken
│ │ ├── repository/ # JPA repositories for all entities
│ │ ├── dto/
│ │ │ ├── request/ # RegisterRequest, LoginRequest, InviteUserRequest,
│ │ │ │ # LocationUpdateRequest, ObstacleLogRequest,
│ │ │ │ # SendNotificationRequest, SosRequest,
│ │ │ │ # AiConfigUpdateRequest, VoiceCommandUpdateRequest,
│ │ │ │ # HardwareShortcutUpdateRequest, GeofenceConfigRequest,
│ │ │ │ # UserSettingsUpdateRequest, and more
│ │ │ └── response/ # ApiResponse (standard wrapper), AuthDataResponse,
│ │ │ # UserProfileResponse, PairingStatusResponse,
│ │ │ # ActivityLogResponse, ObstacleLogResponse,
│ │ │ # LocationResponse, NotificationResponse,
│ │ │ # SosEventResponse, AgoraTokenResponse,
│ │ │ # AiConfigResponse, VoiceCommandResponse,
│ │ │ # HardwareShortcutResponse, GeofenceResponse
│ │ ├── service/ # AuthService, PairingService, ActivityLogService,
│ │ │ # LocationService, ObstacleLogService,
│ │ │ # NotificationService, SosService,
│ │ │ # AiConfigService, VoiceCommandService,
│ │ │ # HardwareShortcutService, GeofenceService,
│ │ │ # UserSettingsService, FcmService,
│ │ │ # AgoraTokenService, GuardianDashboardService
│ │ ├── controller/ # AuthController, PairingController,
│ │ │ # GuardianController, UserController, CallController
│ │ ├── security/ # JwtUtil, JwtAuthFilter, CustomUserDetailsService
│ │ ├── websocket/ # LocationBroadcaster (STOMP broadcaster)
│ │ └── exception/ # GlobalExceptionHandler, ResourceNotFoundException,
│ │ # UnauthorizedException, PairingException
│ ├── src/main/resources/
│ │ ├── application.properties # DB config (PostgreSQL 202.46.28.160:2002), Flyway, JWT
│ │ ├── firebase/
│ │ │ └── google-services-admin.json # FCM service account key (gitignored!)
│ │ └── db/migration/
│ │ ├── V1__create_users_table.sql ✅ Exists
│ │ ├── V2__seed_users.sql ✅ Exists
│ │ ├── V3__link_guardian_user.sql ✅ Exists
│ │ ├── V4__add_unique_user_id.sql 🔄 Needs creation
│ │ ├── V5__create_pairing_relations.sql 🔄 Needs creation
│ │ ├── V6__create_activity_logs.sql 🔄 Needs creation
│ │ ├── V7__create_obstacle_logs.sql 🔄 Needs creation
│ │ ├── V8__create_location_history.sql 🔄 Needs creation
│ │ ├── V9__create_notifications.sql 🔄 Needs creation
│ │ ├── V10__create_sos_events.sql 🔄 Needs creation
│ │ ├── V11__create_user_settings.sql 🔄 Needs creation
│ │ ├── V12__create_ai_configs.sql 🔄 Needs creation
│ │ ├── V13__create_voice_commands.sql 🔄 Needs creation
│ │ ├── V14__create_hardware_shortcuts.sql 🔄 Needs creation
│ │ ├── V15__create_geofence_configs.sql 🔄 Needs creation
│ │ └── V16__create_refresh_tokens.sql 🔄 Needs creation
│ └── src/test/java/com/walkguide/
│ ├── service/ # AuthServiceTest, PairingServiceTest,
│ │ # NotificationServiceTest, SosServiceTest,
│ │ # LocationServiceTest, AiConfigServiceTest
│ └── controller/ # AuthControllerTest, GuardianControllerTest,
│ # UserControllerTest (MockMvc + Testcontainers)
│
├── walkguide-mobile/ # [Flutter Frontend — PENDING]
│ ├── lib/
│ │ ├── main.dart # Firebase init, GetIt setup, camera init
│ │ ├── app/
│ │ │ ├── app.dart # MultiBlocProvider + MaterialApp.router
│ │ │ ├── router.dart # GoRouter: /server-connect → /splash → role-based routes
│ │ │ └── injection_container.dart # GetIt singletons & factories
│ │ ├── core/
│ │ │ ├── constants/ # app_constants.dart (dynamic BASE_URL via SharedPreferences),
│ │ │ │ # route_constants.dart, voice_command_keys.dart
│ │ │ ├── errors/ # failures.dart, exceptions.dart
│ │ │ ├── network/ # api_client.dart (Dio), auth_interceptor.dart,
│ │ │ │ # error_interceptor.dart, network_info.dart
│ │ │ ├── storage/ # secure_storage.dart (JWT), local_database.dart (Drift)
│ │ │ ├── services/ # tts_service.dart, stt_service.dart,
│ │ │ │ # voice_command_handler.dart,
│ │ │ │ # hardware_shortcut_listener.dart,
│ │ │ │ # fcm_service.dart, haptic_service.dart,
│ │ │ │ # websocket_service.dart, agora_service.dart
│ │ │ ├── ai/ # model_loader.dart, yolo_detector.dart,
│ │ │ │ # obstacle_analyzer.dart
│ │ │ ├── theme/ # app_theme.dart, app_colors.dart
│ │ │ └── utils/ # permission_manager.dart, location_service.dart
│ │ ├── features/
│ │ │ ├── server_connect/ # ServerConnectScreen — FIRST SCREEN on fresh install
│ │ │ │ # BLoC: TestConnection, SaveAndContinue, ChangeServer
│ │ │ ├── auth/ # SplashScreen, LoginScreen, RegisterScreen
│ │ │ │ # Clean Arch: domain / data / presentation
│ │ │ ├── pairing/ # UserPairingScreen (show unique ID, accept invite)
│ │ │ │ # GuardianPairingScreen (invite by 12-char ID)
│ │ │ ├── walk_guide/ # WalkGuideScreen (full-screen camera + YOLO overlay)
│ │ │ │ # WalkGuideBloc: Start → camera stream → detect → TTS
│ │ │ ├── sos/ # SosScreen (large SOS button)
│ │ │ ├── activity_log/ # ActivityLogScreen (paginated, filterable)
│ │ │ ├── notifications/ # NotificationScreen + TTS read-all feature
│ │ │ ├── navigation_mode/ # NavigationModeScreen (OSM map + OSRM routing)
│ │ │ ├── settings/ # UserSettingsScreen (TTS, pairing, account)
│ │ │ ├── call/ # CallScreen + IncomingCallScreen (Agora VoIP)
│ │ │ ├── manual/ # ManualScreen (all voice commands & shortcuts)
│ │ │ └── guardian_dashboard/ # GuardianDashboardScreen, GuardianMapScreen,
│ │ │ # GuardianActivityLogScreen, GuardianSendNotifScreen,
│ │ │ # GuardianAiConfigScreen, GuardianVoiceCmdScreen,
│ │ │ # GuardianShortcutScreen, GuardianGeofenceScreen
│ │ └── shared/widgets/ # AppBottomNav, VoiceCommandListener,
│ │ # TtsAwareScaffold, LoadingOverlay, ErrorWidget
│ ├── assets/
│ │ ├── models/
│ │ │ ├── yolov8n.tflite # YOLOv8n converted to TFLite (~6MB)
│ │ │ └── labels.txt # 80 COCO object labels
│ │ └── images/
│ └── pubspec.yaml
│
├── ooad-docs/ # [Design Artifacts]
│ ├── diagrams/ # PlantUML / draw.io exports
│ └── Traceability_Matrix.md
│
└── README.md
|-- walkguide-backend/
| |-- demo/
| |-- src/main/java/com/walkguide/
| | |-- config/
| | |-- controller/
| | |-- dto/
| | |-- entity/
| | |-- enums/
| | |-- exception/
| | |-- repository/
| | |-- security/
| | |-- service/
| | `-- websocket/
| |-- src/main/resources/
| | |-- application.properties
| | |-- application-dev.yml
| | |-- application-prod.yml
| | `-- db/migration/V1...V17
| |-- src/test/java/com/walkguide/
| |-- k6-tests/
| `-- pom.xml
|
|-- walkguide-mobile/
| `-- walkguide_app/
| |-- lib/
| | |-- app/
| | |-- core/
| | |-- features/
| | `-- shared/
| |-- assets/
| | |-- images/
| | `-- models/
| |-- test/
| |-- integration_test/
| `-- pubspec.yaml
|
|-- ooad-docs/
| |-- 01_Builder_Pattern.puml
| |-- 02_Singleton_Pattern.puml
| |-- 03_Facade_Pattern.puml
| |-- 04_Repository_Proxy_Pattern.puml
| |-- 05_Observer_Pattern.puml
| |-- 06_Strategy_Pattern.puml
| |-- 07_ChainOfResponsibility_Pattern.puml
| `-- diagrams/
|
|-- FULL_FLOW_ARCHITECTURE.md
|-- FINAL_EXAM_GUIDE.md
|-- TODO.md
`-- README.md
```
---
## Feature Flows
## Feature Flows (High-Level)
### Flow 1 - Register And Pairing
### Flow 1 — Register & Pairing
Guardian registers → User registers (gets a 12-char `uniqueUserId`) → Guardian enters User's ID → backend sends FCM invite → User accepts in app → backend seeds 14 default voice commands, 5 hardware shortcuts, and AI config for the pair.
Guardian/User registers -> backend creates account and role data -> User receives pairing identity/code -> Guardian sends invite -> User responds -> backend activates pairing and seeds related configs.
### Flow 2 — WalkGuide Active Detection
User says "Start Walkguide" (or presses Volume Down) → camera starts → frames are throttled to max inference FPS → YOLOv8n detects obstacles → direction and distance are analyzed → TTS announces "Caution! Person ahead center. Very close. Please stop." → haptic feedback triggers → obstacle logged to backend → GPS location broadcast via WebSocket to Guardian's live map every 5 seconds.
### Flow 2 - WalkGuide Detection
### Flow 3 — VoIP Call
User says "Call Guardian" → Agora token fetched from backend → FCM "Incoming Call" sent to Guardian → Guardian accepts in `IncomingCallScreen` → both join same Agora RTC channel → audio call connects.
User starts WalkGuide -> camera/AI pipeline runs on device -> obstacle results are analyzed -> TTS/haptic feedback is emitted -> obstacle/location events can be logged to backend.
### Flow 4 — Guardian Sends Notification → User Hears via TTS
Guardian sends text/voice note → backend saves notification + sends FCM → User receives in foreground via WebSocket → `flutter_local_notifications` shown + TTS announced → User says "Read All My Notifications" → TTS reads each message → marked as read.
### Flow 3 - SOS Alert
### Flow 5 — SOS Alert
User says "Send SOS" (or hardware shortcut) → GPS captured → backend saves SOS event → high-priority FCM sent to Guardian → Guardian map opens to User's location → Guardian taps Acknowledge → FCM sent back to User → TTS "Guardian is on the way."
User triggers SOS -> backend creates SOS event -> Guardian receives/loads SOS event -> Guardian taps acknowledge or resolve -> SOS status no longer piles up as unhandled.
### Flow 6 — Geofence
Guardian sets center + radius on map → User's location updates checked against Haversine distance → on exit: FCM to Guardian + `GEOFENCE_EXIT` activity log.
### Flow 4 - Guardian Notification
---
Guardian opens send notification -> chooses text or voice note mode -> sends payload -> backend stores notification -> User notification screen can display/read/play supported message data.
## Quick Start (WIP)
### Flow 5 - Location And Map
```bash
# 1. Clone the repository
git clone https://github.com/YourGroup/walkguide-final-exam.git
User sends location updates -> backend stores location history -> Guardian map/dashboard reads the latest location and history -> WebSocket support exists for real-time updates.
# 2. Run Backend (Spring Boot)
cd walkguide-backend
./mvnw spring-boot:run
# Flyway auto-migrates V1–V16 on first start
# Swagger UI: http://202.46.28.160:8080/swagger-ui.html
# Health check: GET http://202.46.28.160:8080/api/v1/auth/ping
### Flow 6 - Call
# 3. Run Mobile (Flutter) - Connect to local or university backend
cd ../walkguide-mobile
Caller requests token/channel -> backend returns call token payload -> caller notifies target -> target receives incoming call flow -> call can be ended through shared endpoint.
## Quick Start
### Backend
```powershell
cd "D:\CodeSpace\Final Project Gabungan - Broken Test\walkguide-backend\demo"
.\mvnw.cmd spring-boot:run -Dspring-boot.run.profiles=dev
```
Health check:
```text
http://localhost:8080/api/v1/auth/ping
```
Swagger UI:
```text
http://localhost:8080/swagger-ui.html
```
Local/dev configuration reads database and secret values from environment variables or from the gitignored `secrets.properties` file:
```powershell
$env:DB_URL="jdbc:postgresql://<host>:<port>/<database>"
$env:DB_USERNAME="<database_username>"
$env:DB_PASSWORD="<database_password>"
$env:JWT_SECRET="your-base64-secret"
```
### Flutter
```powershell
cd "D:\CodeSpace\Final Project Gabungan - Broken Test\walkguide-mobile\walkguide_app"
flutter pub get
flutter run
# On first launch: enter server URL in ServerConnectScreen
# Example: http://202.46.28.160:8080
```
---
For Chrome/web debug:
```powershell
flutter run -d chrome -t lib/main.dart
```
For Android APK:
```powershell
flutter build apk --release
```
On a physical phone, the server URL must be reachable by the phone. Use the university server URL or your laptop LAN IP, not `localhost`.
## Results
> ⏳ **Work In Progress:** Results are populated as benchmarking phases are completed.
Final benchmark values should be filled from real test runs before submission.
| Metric | Baseline | Final Optimized | Status |
|---|---|---|---|
| Cold Start Time | *Pending* | *Pending* | ⏳ |
| Memory Leak (10 Navs) | *Pending* | *Pending* | ⏳ |
| API Error Rate | *Pending* | *Pending* | ⏳ |
| YOLO Inference Latency (ms) | *Pending* | *Pending* | ⏳ |
| API p95 Latency | *Pending* | *Pending* | ⏳ |
| JaCoCo Coverage | *Pending* | *Pending* | ⏳ |
---
| Metric | Evidence Location / Tool | Current README Status |
|---|---|---|
| Backend tests | Maven/JUnit output | To be generated |
| Backend coverage | JaCoCo report | To be generated |
| Backend load | k6 results | Assets present, final run needed |
| Flutter tests | `flutter test` / integration tests | To be generated |
| Flutter performance | Physical Android profile evidence | To be generated |
| APK size | `flutter build apk --analyze-size` | To be generated |
## Weekly Progress
| Week | Target | Status |
| Week | Target | Current Status |
|---|---|---|
| 1 | Topic proposal, Use Case definitions, Repo setup | ✅ Done |
| 2–3 | OOAD diagrams, OpenAPI YAML drafted | 🔄 In Progress |
| 4 | Spring Boot: Auth, Pairing, Entity, Migration V4–V16 | 🔄 In Progress |
| 5 | Spring Boot: Location, SOS, Notification, WS, FCM, Agora | ⏳ Pending |
| 6 | Spring Boot: Unit + Integration tests, JaCoCo ≥ 70% | ⏳ Pending |
| 7 | Flutter: ServerConnect, Auth, WalkGuide + YOLO pipeline | ⏳ Pending |
| 8 | Flutter: Guardian dashboard, Call, SOS, Notifications | ⏳ Pending |
| 9 | Feature freeze, integration testing, benchmark on device | ⏳ Pending |
| 10 | Final benchmarks, Report writing, Demo Video | ⏳ Pending |
---
| 1 | Topic proposal, use case definitions, repo setup | Done |
| 2-3 | OOAD diagrams and traceability | Done, docs present in `ooad-docs/` |
| 4 | Spring Boot auth, pairing, entities, migrations | Implemented |
| 5 | Location, SOS, notification, WebSocket, FCM, call support | Implemented with demo/service integrations |
| 6 | Backend unit/integration testing and coverage setup | Implemented, final run evidence needed |
| 7 | Flutter server connect, auth, WalkGuide/YOLO support | Implemented |
| 8 | Guardian dashboard, SOS, notifications, voice notes, settings | Implemented |
| 9 | Integration testing and benchmark evidence | Needs final evidence run |
| 10 | Report, demo video, final submission polish | In progress |
## Core Objectives
**O₁ (Performance):** The dual-platform architecture must maintain a 60fps UI frame rate while handling concurrent backend requests without exceeding 500ms latency. YOLO inference runs on a separate isolate to avoid blocking the UI thread.
O1 Performance: Keep obstacle detection local/on-device where possible and use backend for persistence, pairing, configuration, and real-time coordination.
**O₂ (Accessibility):** The User mode must require zero visual interaction post-login, relying entirely on haptics, voice commands, and physical button mapping. All screen transitions are announced via TTS.
O2 Accessibility: Support voice/TTS, haptic feedback, large touch targets, and hardware shortcut flows for visually impaired users.
**O₃ (Traceability):** Every major feature implementation must be directly traceable back to the pre-development OOAD artifacts and GoF design patterns.
O3 Traceability: Keep implementation aligned with `FULL_FLOW_ARCHITECTURE.md`, `FINAL_EXAM_GUIDE.md`, and the PUML diagrams in `ooad-docs/`.
**O₄ (Configurability):** All AI sensitivity settings, voice command phrases, and hardware shortcuts are remotely configurable by the Guardian without requiring an app update.
---
O4 Configurability: Let Guardian configure AI sensitivity, voice commands, hardware shortcuts, geofence, and User settings through dashboard flows.
## License
Distributed under the [MIT License](LICENSE).
---
*Final Exam: Integrated Mobile Application Project* <br>
*Flutter × Spring Boot × Object-Oriented Analysis and Design*
Final Exam: Integrated Mobile Application Project
Flutter x Spring Boot x Object-Oriented Analysis and Design
+3023
View File
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
' ============================================================
' WALKGUIDE — DESIGN PATTERNS (GoF)
' Flutter × Spring Boot × In-Device AI
' Pattern 1 of 7: BUILDER (Creational)
' ============================================================
@startuml WalkGuide_01_Builder
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
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+110
View File
@@ -0,0 +1,110 @@
' ============================================================
' WALKGUIDE — DESIGN PATTERNS (GoF)
' Flutter × Spring Boot × In-Device AI
' Pattern 2 of 7: SINGLETON (Creational)
' ============================================================
@startuml WalkGuide_02_Singleton
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 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
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+110
View File
@@ -0,0 +1,110 @@
' ============================================================
' WALKGUIDE — DESIGN PATTERNS (GoF)
' Flutter × Spring Boot × In-Device AI
' Pattern 3 of 7: FACADE (Structural)
' ============================================================
@startuml WalkGuide_03_Facade
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 3 — FACADE (Structural) — Flutter
' ============================================================
package "③ Facade Pattern [Structural]" #FFF8E1 {
class "VoiceCommandHandler\n<<Facade>>" as VoiceCommandHandler {
- _ttsService : TtsService
- _sttService : SttService
- _router : CommandRouter
- _actions : Map<VoiceCommandKey, CommandAction>
+ processText(String command) : void
- _matchCommand(String) : VoiceCommandKey?
- _executeCommand(VoiceCommandKey) : void
}
class "WalkGuideCubit\n<<Client>>" as WalkGuideCubitFacade {
+ 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 "CommandRouter\n<<Router Adapter>>" as GoRouterFacade <<Subsystem>>
class "CommandAction\n<<Cubit Callback>>" as CommandActionFacade <<Subsystem>>
class "LocationService\n<<Service>>" as LocationService <<Subsystem>>
class "ActivityLogService\n<<Service>>" as ActivityService <<Subsystem>>
class "SosService\n<<Service>>" as SosServiceFacade <<Subsystem>>
class "NotificationService\n<<Service>>" as NotifServiceFacade <<Subsystem>>
class "GuardianDashboardController\n<<Client>>" as GuardianDashboardController {
+ getDashboard(guardianId) : ResponseEntity
' GET /api/v1/guardian/dashboard
}
WalkGuideCubitFacade --> VoiceCommandHandler : processText()
VoiceCommandHandler --> SttServiceFacade : delegates
VoiceCommandHandler --> TtsServiceFacade : delegates
VoiceCommandHandler --> GoRouterFacade : delegates
VoiceCommandHandler --> CommandActionFacade : delegates
GuardianDashboardController --> GuardianDashboardService : getDashboard()
GuardianDashboardService --> LocationService : aggregates
GuardianDashboardService --> ActivityService : aggregates
GuardianDashboardService --> SosServiceFacade : aggregates
GuardianDashboardService --> NotifServiceFacade : 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
note right of GuardianDashboardService
Client (Controller) hanya panggil getDashboard(guardianId)
Facade mengagregasi 4 service sekaligus:
location + activity + SOS + notification
→ satu response, satu request dari Flutter
end note
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+101
View File
@@ -0,0 +1,101 @@
' ============================================================
' WALKGUIDE — DESIGN PATTERNS (GoF)
' Flutter × Spring Boot × In-Device AI
' Pattern 4 of 7: REPOSITORY / PROXY (Structural)
' ============================================================
@startuml WalkGuide_04_Repository_Proxy
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 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 {
- _apiClient : ApiClient
- _offlineQueue : OfflineQueueService
- _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 "ApiClient\n<<Remote>>" as RemoteDSWalk {
+ startSession() : void
+ logObstacle(req) : void
' POST /api/v1/user/obstacle
}
class "OfflineQueueService + LocalDatabase\n<<SQLite Cache>>" as LocalDSWalk {
+ cacheObstacle(ObstacleLog) : void
+ getPendingLogs() : List<ObstacleLog>
' SQLite-backed 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
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+98
View File
@@ -0,0 +1,98 @@
' ============================================================
' WALKGUIDE — DESIGN PATTERNS (GoF)
' Flutter × Spring Boot × In-Device AI
' Pattern 5 of 7: OBSERVER (Behavioral)
' ============================================================
@startuml WalkGuide_05_Observer
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 5 — OBSERVER (Behavioral)
' ============================================================
package "⑤ Observer Pattern [Behavioral]" #F3E5F5 {
abstract class "Cubit<State>\n<<Subject>>" as BlocSubject {
# stateController : StreamController<State>
+ emit(State state)
+ stream : Stream<State>
}
class "WalkGuideCubit\n<<ConcreteSubject>>" as WalkGuideCubitObs {
+ start()
+ stop()
+ logObstacle()
- _yoloDetector : YoloDetector
- _ttsService : TtsService
- _hapticService : HapticService
}
class "BlocBuilder<WalkGuideCubit, WalkGuideState>\n<<Observer / Widget>>" as BlocBuilderWidget {
+ builder(ctx, state) : Widget
' Rebuilds UI on every state emission
}
class "BlocListener<WalkGuideCubit, 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 <|-- WalkGuideCubitObs : extends
WalkGuideCubitObs --> BlocBuilderWidget : emits state\n(rebuilds UI)
WalkGuideCubitObs --> 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
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+99
View File
@@ -0,0 +1,99 @@
' ============================================================
' WALKGUIDE — DESIGN PATTERNS (GoF)
' Flutter × Spring Boot × In-Device AI
' Pattern 6 of 7: STRATEGY (Behavioral)
' ============================================================
@startuml WalkGuide_06_Strategy
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 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
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

@@ -0,0 +1,137 @@
' ============================================================
' WALKGUIDE — DESIGN PATTERNS (GoF)
' Flutter × Spring Boot × In-Device AI
' Pattern 7 of 7: CHAIN OF RESPONSIBILITY (Behavioral)
' ============================================================
@startuml WalkGuide_07_ChainOfResponsibility
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 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
+383
View File
@@ -0,0 +1,383 @@
# Mapping Class per Design Pattern — WalkGuide
> Dokumen ini memetakan setiap class dari **Class Diagram WalkGuide** ke design pattern yang menggunakannya, beserta peran class tersebut dalam pattern dan justifikasi pemilihannya.
---
## Daftar Isi
1. [Matriks Ringkasan](#1-matriks-ringkasan)
2. [Builder Pattern](#2-builder-pattern--creational)
3. [Singleton Pattern](#3-singleton-pattern--creational)
4. [Facade Pattern](#4-facade-pattern--structural)
5. [Repository / Proxy Pattern](#5-repository--proxy-pattern--structural)
6. [Observer Pattern](#6-observer-pattern--behavioral)
7. [Strategy Pattern](#7-strategy-pattern--behavioral)
8. [Chain of Responsibility Pattern](#8-chain-of-responsibility-pattern--behavioral)
---
## 1. Matriks Ringkasan
| Pattern | Kategori | Class Utama (dari Class Diagram) |
|---------|----------|----------------------------------|
| Builder | Creational | `User`, `AuthService`, `FcmService`, `AuthDataResponse` |
| Singleton | Creational | `TtsService`, `SttService`, `YoloDetector`, `WebSocketService`, `AgoraService`, `HapticService` |
| Facade | Structural | `VoiceCommandHandler`, `GuardianDashboardService`, `GuardianController`, `VoiceCommandConfig` |
| Repository (Proxy) | Structural | `WalkGuideRepositoryImpl`, `ActivityLogRepositoryImpl`, `LocationRepositoryImpl`, `ObstacleLogRepository`, `ActivityLogRepository` |
| Observer | Behavioral | `WalkGuideBloc`, `SosBloc`, `WebSocketService`, `LocationBroadcaster`, `GuardianNotificationRepository` |
| Strategy | Behavioral | `ObstacleAnalyzer`, `AiConfig`, `AiConfigService`, `TtsService`, `HapticService` |
| Chain of Responsibility | Behavioral | `ApiClient`, `AuthInterceptor`, `ErrorInterceptor`, `JwtAuthFilter`, `SecurityConfig`, `JwtUtil`, `CustomUserDetailsService` |
---
## 2. Builder Pattern — Creational
### Justifikasi
Entitas `User` memiliki **8 field** — tidak semua selalu terisi. Field `uniqueUserId` hanya ada untuk `ROLE_USER`, `fcmToken` di-update setiap login, dan `displayName` bersifat opsional. Tanpa Builder, constructor membutuhkan 8 parameter yang rawan salah urutan dan sulit dibaca. `FcmService` membangun payload Firebase `Message` secara kondisional (beberapa field hanya ada saat SOS, yang lain hanya saat notifikasi biasa) — Builder sangat cocok untuk kasus ini.
### Class yang Terlibat
| Class | Layer | Peran dalam Pattern | Lokasi di Class Diagram |
|-------|-------|---------------------|-------------------------|
| `User` | Backend — Entity | **Product** — objek yang dibangun step-by-step via `@Builder` Lombok | Entity block |
| `AuthService` | Backend — Service | **Director/Client** — memanggil `User.builder()...build()` saat register | Service block |
| `FcmService` | Backend — Service | **Client** — memanggil `Message.builder()` Firebase untuk bangun payload FCM | Service block |
| `AuthDataResponse` | Backend — DTO | **Product 2** — response token dibangun via `AuthDataResponse.builder()` | DTO block |
| `RefreshToken` | Backend — Entity | **Product 3** — dibuat via builder saat generate refresh token | Entity block |
### Relasi Antar Class
```
AuthService ──uses──► User.builder()
.email()
.password()
.displayName()
.uniqueUserId()
.role()
.build()
│
▼
User (Entity tersimpan ke UserRepository)
FcmService ──uses──► Message.builder()
.setToken()
.setNotification()
.putData("type", "SOS_ALERT")
.setAndroidConfig()
.build()
│
▼
FirebaseMessage (dikirim via FCM)
```
---
## 3. Singleton Pattern — Creational
### Justifikasi
WalkGuide memiliki beberapa **resource-heavy services** yang harus ada tepat satu instance:
- `YoloDetector` memuat model `.tflite` ±6 MB ke memori — jika dibuat ulang setiap buka layar akan terjadi memory leak dan delay 2–3 detik.
- `WebSocketService` mempertahankan satu koneksi STOMP ke backend — multiple instance menyebabkan event diterima dobel.
- `TtsService` mengelola engine TTS — dua instance berbicara bersamaan akan saling menimpa.
- `AgoraService` mengelola RTC engine — satu per sesi, tidak boleh duplikat.
Semua di-register via `GetIt` service locator sebagai singleton agar seluruh widget tree mendapat instance yang sama.
### Class yang Terlibat
| Class | Layer | Peran dalam Pattern | Lokasi di Class Diagram |
|-------|-------|---------------------|-------------------------|
| `TtsService` | Flutter — Service | **Singleton** — satu TTS engine untuk seluruh app | Services block |
| `SttService` | Flutter — Service | **Singleton** — satu STT listener always-on | Services block |
| `YoloDetector` | Flutter — AI | **Singleton** — satu model inference engine (6 MB) | AI block |
| `WebSocketService` | Flutter — Service | **Singleton** — satu koneksi STOMP ke backend | Services block |
| `AgoraService` | Flutter — Service | **Singleton** — satu RTC engine untuk VoIP call | Services block |
| `HapticService` | Flutter — Service | **Singleton** — satu haptic controller | Services block |
| `GetIt` (injection_container) | Flutter — DI | **ServiceLocator** — mengelola lifecycle semua singleton | Bootstrap |
### Relasi Antar Class
```
injection_container.dart
│
├─ sl.registerSingleton<TtsService>(TtsService())
├─ sl.registerSingleton<SttService>(SttService())
├─ sl.registerSingleton<YoloDetector>(YoloDetector())
├─ sl.registerSingleton<WebSocketService>(WebSocketService())
├─ sl.registerSingleton<AgoraService>(AgoraService())
└─ sl.registerSingleton<HapticService>(HapticService())
WalkGuideBloc ──sl<YoloDetector>()──► YoloDetector (instance sama)
WalkGuideBloc ──sl<TtsService>()───► TtsService (instance sama)
VoiceCommandHandler ──sl<TtsService>()──► TtsService (instance sama, bukan baru)
```
---
## 4. Facade Pattern — Structural
### Justifikasi
Memproses satu perintah suara `"Send SOS"` membutuhkan koordinasi: `SttService`, command matcher, `SosBloc`, GPS, `TtsService` feedback, dan `GoRouter` navigasi — 6 subsystem berbeda. Tanpa Facade, setiap Widget harus inject dan tahu semua 6 dependency tersebut. `VoiceCommandHandler` menyederhanakan ini menjadi satu panggilan `processText(string)`.
Di backend, `GuardianController` membutuhkan data dari 5 repository berbeda untuk render dashboard Guardian. `GuardianDashboardService` menjadi Facade yang mengagregasi semuanya, sehingga controller cukup memanggil satu method.
### Class yang Terlibat
| Class | Layer | Peran dalam Pattern | Lokasi di Class Diagram |
|-------|-------|---------------------|-------------------------|
| `VoiceCommandHandler` | Flutter — Service | **Facade** — menyembunyikan koordinasi 6 subsystem voice | Services block |
| `WalkGuideBloc` | Flutter — BLoC | **Client** — hanya tahu `processText()`, tidak tahu subsystem | BLoC block |
| `TtsService` | Flutter — Service | **Subsystem** — salah satu dari yang dikoordinasikan | Services block |
| `SttService` | Flutter — Service | **Subsystem** — salah satu dari yang dikoordinasikan | Services block |
| `VoiceCommandConfig` | Backend — Entity | **Data** — konfigurasi perintah suara yang dibaca Facade | Entity block |
| `VoiceCommandKey` | Backend — Enum | **Enum** — key identifier 14 perintah suara | Enum block |
| `GuardianDashboardService` | Backend — Service | **Facade** — mengagregasi data dari 5 repository | Service block |
| `GuardianController` | Backend — Controller | **Client** — cukup panggil `getGuardianHomeData()` | Controller block |
| `LocationHistoryRepository` | Backend — Repository | **Subsystem** — data lokasi terakhir User | Repository block |
| `SosEventRepository` | Backend — Repository | **Subsystem** — data SOS aktif User | Repository block |
| `ActivityLogRepository` | Backend — Repository | **Subsystem** — log aktivitas User | Repository block |
| `GuardianNotificationRepository` | Backend — Repository | **Subsystem** — jumlah notif belum dibaca | Repository block |
| `PairingRelationRepository` | Backend — Repository | **Subsystem** — ambil userId yang dipasangkan ke Guardian | Repository block |
### Relasi Antar Class
```
Widget ──processText("send sos")──► VoiceCommandHandler (Facade)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
SttService SosBloc TtsService
(stop listen) (TriggerSos) (speak feedback)
GuardianController ──getGuardianHomeData()──► GuardianDashboardService (Facade)
│
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
LocationHistoryRepo SosEventRepo ActivityLogRepo
(last location) (active SOS) (recent activity)
```
---
## 5. Repository / Proxy Pattern — Structural
### Justifikasi
WalkGuide harus berjalan **offline-first** — tunanetra sering berada di area tanpa sinyal. Log obstacle, activity log, dan update lokasi harus tetap tersimpan lokal (SQLite/Drift) saat offline, kemudian di-sync ke backend saat koneksi kembali. `WalkGuideBloc` tidak boleh tahu apakah data berasal dari API atau SQLite — keputusan ini ada di Repository Implementation yang bertindak sebagai Proxy.
### Class yang Terlibat
| Class | Layer | Peran dalam Pattern | Lokasi di Class Diagram |
|-------|-------|---------------------|-------------------------|
| `WalkGuideRepository` (interface) | Flutter — Domain | **Subject Interface** — kontrak yang dipakai BLoC | Domain layer |
| `WalkGuideRepositoryImpl` | Flutter — Data | **Proxy** — memutuskan API vs SQLite | Data layer |
| `ActivityLogRepository` (interface) | Flutter — Domain | **Subject Interface** | Domain layer |
| `ActivityLogRepositoryImpl` | Flutter — Data | **Proxy** | Data layer |
| `ObstacleLogRepository` | Backend — Repository | **Real Subject** — JPA repository backend | Repository block |
| `ActivityLogRepository` (JPA) | Backend — Repository | **Real Subject** — JPA repository backend | Repository block |
| `LocationHistoryRepository` | Backend — Repository | **Real Subject** — JPA repository backend | Repository block |
| `ObstacleLog` | Backend — Entity | **Data** — entitas yang disimpan/diambil | Entity block |
| `ActivityLog` | Backend — Entity | **Data** — entitas yang disimpan/diambil | Entity block |
| `LocationHistory` | Backend — Entity | **Data** — riwayat lokasi User | Entity block |
### Relasi Antar Class
```
WalkGuideBloc
│
│ depends on interface only
▼
WalkGuideRepository (interface)
│ implements
▼
WalkGuideRepositoryImpl (Proxy)
│
├─ if (isOnline) ──► REST API ──► ObstacleLogRepository (JPA)
│ │
│ ▼
│ ObstacleLog (Entity)
│
└─ else ────────► SQLite/Drift ──► Local cache
(Drift ORM)
```
---
## 6. Observer Pattern — Behavioral
### Justifikasi
Saat YOLO mendeteksi obstacle, puluhan komponen perlu bereaksi: overlay bounding box, TTS feedback, haptic, backend log, dan debug panel. Menghubungkan semua ini secara langsung akan menciptakan tight coupling. BLoC pattern adalah implementasi Observer murni — `WalkGuideBloc` sebagai Subject, widget sebagai Observer. Ketika state baru di-emit, semua subscriber otomatis bereaksi tanpa BLoC perlu tahu siapa saja Observer-nya.
Untuk Guardian, `WebSocketService` adalah Observer terhadap backend STOMP — Guardian menerima update lokasi User real-time tanpa polling.
### Class yang Terlibat
| Class | Layer | Peran dalam Pattern | Lokasi di Class Diagram |
|-------|-------|---------------------|-------------------------|
| `WalkGuideBloc` | Flutter — BLoC | **Concrete Subject** — emit state saat obstacle terdeteksi | BLoC block |
| `SosBloc` | Flutter — BLoC | **Concrete Subject** — emit state saat SOS triggered | BLoC block |
| `BlocBuilder` (widget) | Flutter — UI | **Observer** — rebuild UI saat state berubah | Widget layer |
| `BlocListener` (widget) | Flutter — UI | **Observer** — side effects tanpa rebuild UI | Widget layer |
| `WebSocketService` | Flutter — Service | **Subject** — Stream lokasi, SOS, notifikasi | Services block |
| `LocationBroadcaster` | Backend — Component | **Publisher** — push event ke STOMP topics | Backend block |
| `GuardianNotificationRepository` | Backend — Repository | **Data Store** — notifikasi yang perlu di-broadcast | Repository block |
| `SosEventRepository` | Backend — Repository | **Data Store** — SOS event yang di-broadcast ke Guardian | Repository block |
| `ActivityLogService` | Backend — Service | **Publisher** — broadcast activity log via LocationBroadcaster | Service block |
### Relasi Antar Class
```
YoloDetector ──detect()──► DetectionResult
│
WalkGuideBloc (Subject)
emit(ObstacleDetected)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
BlocBuilder BlocListener BlocConsumer
(UI rebuild) (side effects) (both)
overlay bbox SnackBar error combined
Backend STOMP push:
LocationBroadcaster ──/topic/location/{userId}──► WebSocketService (Subject)
│
┌─────────────┼─────────────┐
▼ ▼ ▼
GuardianMap GuardianDash NotifScreen
(live marker) (SOS alert) (new notif)
```
---
## 7. Strategy Pattern — Behavioral
### Justifikasi
Guardian perlu mengkonfigurasi **cara WalkGuide memperingatkan** User tentang obstacle. Ada tiga mode: TTS saja (cocok untuk lingkungan tenang), TTS + haptic (default untuk sebagian besar situasi), haptic saja (lingkungan bising seperti pasar atau stasiun). Logika ini bisa berubah kapan saja dari dashboard Guardian tanpa restart app. Strategy Pattern memungkinkan penggantian algoritma alert di runtime tanpa mengubah `ObstacleAnalyzer` sedikit pun.
### Class yang Terlibat
| Class | Layer | Peran dalam Pattern | Lokasi di Class Diagram |
|-------|-------|---------------------|-------------------------|
| `ObstacleAlertStrategy` (interface) | Flutter — AI | **Strategy Interface** — kontrak `alert(result)` | Domain/AI layer |
| `TtsOnlyStrategy` | Flutter — AI | **Concrete Strategy 1** — hanya TTS | Strategy layer |
| `TtsWithHapticStrategy` | Flutter — AI | **Concrete Strategy 2** — TTS + haptic | Strategy layer |
| `HapticOnlyStrategy` | Flutter — AI | **Concrete Strategy 3** — hanya haptic | Strategy layer |
| `ObstacleAnalyzer` | Flutter — AI | **Context** — menyimpan dan mendelegasikan ke strategy | AI block |
| `AiConfig` | Backend — Entity | **Config Source** — menyimpan `alertMode` yang dipilih Guardian | Entity block |
| `AiConfigService` | Backend — Service | **Config Manager** — update config dan kirim FCM ke User | Service block |
| `AiConfigRepository` | Backend — Repository | **Persistence** — simpan/ambil konfigurasi AI | Repository block |
| `TtsService` | Flutter — Service | **Subsystem** — dipakai oleh TtsOnly dan TtsWithHaptic | Services block |
| `HapticService` | Flutter — Service | **Subsystem** — dipakai oleh TtsWithHaptic dan HapticOnly | Services block |
| `FcmService` | Backend — Service | **Notifier** — kirim FCM ke User saat config berubah | Service block |
### Relasi Antar Class
```
Guardian PUT /guardian/ai-config
│
▼
AiConfigService ──updateConfig()──► AiConfig (simpan alertMode)
│
└──► FcmService.sendToUser() ──► FCM ke User: "ai_config_updated"
│
Flutter terima FCM
│
switch (config.alertMode)
┌─────────┼──────────┐
▼ ▼ ▼
TtsOnly TtsHaptic HapticOnly
Strategy Strategy Strategy
│
ObstacleAnalyzer.setStrategy(new)
│
_strategy.alert(detectionResult)
```
---
## 8. Chain of Responsibility Pattern — Behavioral
### Justifikasi
Setiap HTTP request dari Flutter perlu melewati beberapa proses berurutan: (1) menambahkan JWT, (2) auto-refresh jika expired, (3) error mapping terpusat, (4) logging. Tanpa Chain of Responsibility, setiap Repository harus menulis ulang logika ini. Di backend, setiap request perlu (1) divalidasi JWT, (2) dicek role-nya sebelum mencapai controller bisnis. Chain memungkinkan setiap handler fokus pada tanggung jawabnya sendiri dan meneruskan ke handler berikutnya.
### Class yang Terlibat
| Class | Layer | Peran dalam Pattern | Lokasi di Class Diagram |
|-------|-------|---------------------|-------------------------|
| `Interceptor` (abstract Dio) | Flutter — Network | **Abstract Handler** | Network layer |
| `AuthInterceptor` | Flutter — Network | **Concrete Handler 1** — inject token, auto-refresh | Network layer |
| `ErrorInterceptor` | Flutter — Network | **Concrete Handler 2** — map HTTP error ke Failure | Network layer |
| `LogInterceptor` | Flutter — Network | **Concrete Handler 3** — logging request/response | Network layer |
| `ApiClient` | Flutter — Network | **Chain Builder** — mengatur urutan dan membangun chain | Network layer |
| `OncePerRequestFilter` (Spring) | Backend — Security | **Abstract Handler** | Security block |
| `JwtAuthFilter` | Backend — Security | **Concrete Handler 1** — validasi JWT, set SecurityContext | Security block |
| `SecurityConfig` | Backend — Security | **Concrete Handler 2** — RBAC routing per URL pattern | Security block |
| `JwtUtil` | Backend — Security | **Component** — dipakai JwtAuthFilter untuk decode/validate | Security block |
| `CustomUserDetailsService` | Backend — Security | **Component** — dipakai JwtAuthFilter untuk load user dari DB | Security block |
| `UserRepository` | Backend — Repository | **Data Source** — dipakai CustomUserDetailsService | Repository block |
| `GlobalExceptionHandler` | Backend — Exception | **Error Handler** — menangkap exception dari seluruh chain | Exception block |
### Relasi Antar Class
```
Flutter HTTP Request
│
▼
[Chain[0]] AuthInterceptor ──onRequest()──► inject "Bearer {token}" ──► next()
│
▼
[Chain[1]] ErrorInterceptor ──onRequest()──► pass-through ──► next()
│
▼
[Chain[2]] LogInterceptor ──onRequest()──► log URL + body ──► next()
│
▼ (HTTP over network)
│
[Backend] JwtAuthFilter ──doFilterInternal()──► validate JWT ──► chain.doFilter()
│
▼
[Backend] SecurityConfig ──securityFilterChain()──► cek role ──► pass
│
▼
[Backend] Controller ──handleRequest()──► proses bisnis
│
▼
[Backend] GlobalExceptionHandler ──► tangkap exception jika ada
│
▼ (HTTP Response)
│
[Chain[2]] LogInterceptor.onResponse() ──► log response
│
▼
[Chain[1]] ErrorInterceptor.onResponse() ──► map error jika 4xx/5xx
│
▼
[Chain[0]] AuthInterceptor.onError() ──► jika 401: refresh token + retry
│
▼
Repository / BLoC menerima Either<Failure, Data>
```
---
## Ringkasan Jumlah Class per Pattern
| Pattern | Jumlah Class Terlibat | Layer |
|---------|----------------------|-------|
| Builder | 5 class | Backend |
| Singleton | 7 class | Flutter |
| Facade | 13 class | Flutter + Backend |
| Repository/Proxy | 10 class | Flutter + Backend |
| Observer | 9 class | Flutter + Backend |
| Strategy | 11 class | Flutter + Backend |
| Chain of Responsibility | 12 class | Flutter + Backend |
| **Total unik** | **±40 class** | **Full Stack** |
> Semua class di atas dapat ditelusuri langsung di **Class Diagram WalkGuide** (file PDF yang dilampirkan).
+499
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
+994
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.
+13 -22
View File
@@ -1,29 +1,20 @@
# WalkGuide Traceability Audit
## Use Case To Code
Dokumen audit utama Phase 1C sudah dibuat di:
| Use Case | Flutter Entry | Backend Entry | Status |
|---|---|---|---|
| Register/Login | `features/auth/*` | `AuthController`, `AuthService` | Implemented |
| Pair Guardian/User | `features/pairing/pairing_screens.dart` | `PairingController`, `PairingService` | Implemented |
| Start/Stop WalkGuide | `features/walk_guide/walk_guide_screen.dart` | `POST /user/walkguide/start`, `POST /user/walkguide/stop` | Implemented |
| Obstacle Detection | `core/ai/*`, `walk_guide_screen.dart` | `POST /user/obstacle` | Partially implemented; real `.tflite` model file still required |
| Location Tracking | `LocationReporterService`, navigation/walk screens | `LocationService`, `LocationBroadcaster` | Implemented |
| SOS | `features/sos/sos_screen.dart` | `SosService`, `GuardianController` | Implemented |
| Notifications | `features/notifications/notification_screen.dart` | `NotificationService`, `FcmService` | Partially implemented; backend FCM is log-only without credentials |
| Guardian Dashboard | `guardian_dashboard_screen.dart` | `GuardianDashboardService` | Implemented |
| Call | `features/call/call_screen.dart`, `CallService` | `CallController`, `AgoraTokenService` | Partially implemented; Agora App ID/credential required for live call |
| Geofence | guardian dashboard screens | `GeofenceService` | Implemented |
`ooad-docs/design-audit/Phase1C_Design_Traceability_Audit_WalkGuide.md`
## Design Deviations
Gunakan file tersebut sebagai sumber utama untuk bagian laporan:
- Flutter uses a pragmatic mix of BLoC/Cubit, ChangeNotifier, and StatefulWidget. This deviates from strict full-BLoC architecture but keeps feature screens small for the exam demo.
- Offline storage uses `SharedPreferences` queue instead of a full Drift database for cached entities. The dependency exists, but production-grade SQLite cache is not fully wired.
- Backend FCM service is log-only until Firebase Admin credentials are provided.
- Agora call flow is implemented at API and service level, but live RTC depends on a real Agora App ID.
- OOAD - Design Traceability Audit
- Class-to-code mapping
- Design pattern audit
- Sequence diagram trace
- Design deviation log
- Audit summary and sign-off
## Remaining Evidence
Catatan penting:
- Export rendered OOAD diagrams from the PlantUML files in this folder.
- Run backend tests and archive JaCoCo/Surefire reports.
- Run Flutter benchmarks on a physical Android device and add screenshots/results.
- Real YOLO/TFLite berjalan di Android/native.
- Chrome/Web memakai fallback demo karena `tflite_flutter` bergantung pada `dart:ffi`, yang tidak tersedia di Web.
- Backend test, Flutter test, Web build, dan Android debug APK build sudah lulus pada audit terakhir.
@@ -0,0 +1,586 @@
# Phase 1C - Design Traceability Audit
## WalkGuide - Post-Development OOAD Audit
**Project:** WalkGuide AI
**Domain:** AI-assisted navigation and guardian monitoring for visually impaired users
**Stack:** Flutter, Spring Boot, PostgreSQL, WebSocket STOMP, on-device YOLO/TFLite
**Audit Basis:** `FULL_FLOW_ARCHITECTURE.md`, `ketentuan.md`, `Phase1C_Design_Traceability_Audit_Guide.pdf`, and current source code
**Audit Status:** Conditional Pass - functionally integrated, with documented design deviations
---
## 0. Audit Method
This audit follows the Phase 1C guide:
1. Map designed classes to actual source files.
2. Verify GoF design patterns with exact file and line references.
3. Trace sequence diagram flows into real method call chains.
4. Document all design deviations with rationale and impact.
5. Summarize evidence, risks, and required follow-up actions.
Reproducibility commands used:
```powershell
rg --files walkguide-backend\demo\src\main\java walkguide-mobile\walkguide_app\lib
rg -n "class |interface |enum |@Entity|@RestController|@Service|@Repository|extends Cubit|BlocProvider|registerLazySingleton" walkguide-backend\demo\src\main\java walkguide-mobile\walkguide_app\lib
rg -n "@(Get|Post|Put|Delete)Mapping|@RequestMapping" walkguide-backend\demo\src\main\java\com\walkguide\controller
```
Validation commands executed after the latest implementation:
```powershell
flutter analyze
flutter test
flutter build web --no-pub
flutter build apk --debug --no-pub
.\mvnw.cmd test
```
Results:
| Area | Command | Result |
|---|---|---|
| Flutter static analysis | `flutter analyze` | Passed, no issues |
| Flutter automated tests | `flutter test` | Passed, 291 tests |
| Flutter Web build | `flutter build web --no-pub` | Passed |
| Flutter Android build | `flutter build apk --debug --no-pub` | Passed |
| Spring Boot tests | `.\mvnw.cmd test` | BUILD SUCCESS, 331 tests, 0 failures, 0 errors, 56 skipped |
---
## 1. Class Traceability Matrix
Status legend:
| Status | Meaning |
|---|---|
| OK | Designed class exists in code and matches intent |
| RENAMED/MERGED | Design intent exists but class name or boundary changed |
| ADDED | Implementation class added beyond original design |
| PARTIAL | Exists but with reduced scope or platform limitation |
| MISSING | Designed class is not implemented |
### 1.1 Backend Domain Entities and Database Tables
| Design Class / Table | Status | Code File | Evidence | Notes |
|---|---|---|---|---|
| `User` / `users` | OK | `walkguide-backend/demo/src/main/java/com/walkguide/entity/User.java` | `User.java:13`, `V1__create_users_table.sql:4` | Uses `role` string for `ROLE_GUARDIAN` and `ROLE_USER`; `uniqueUserId` is only for user role. |
| `PairingRelation` / `pairing_relations` | OK | `entity/PairingRelation.java` | `PairingRelation.java:14`, `V5__create_pairing_relations.sql:5` | Represents guardian-user pairing and active/pending/rejected state. |
| `ActivityLog` / `activity_logs` | OK | `entity/ActivityLog.java` | `ActivityLog.java:14`, `V6__create_activity_logs.sql:2` | Stores system/user activity history. |
| `ObstacleLog` / `obstacle_logs` | OK | `entity/ObstacleLog.java` | `ObstacleLog.java:13`, `V7__create_obstacle_logs.sql:2` | Stores YOLO label, confidence, direction, distance, and location. |
| `LocationHistory` / `location_history` | OK | `entity/LocationHistory.java` | `LocationHistory.java:13`, `V8__create_location_history.sql:2` | Supports location timeline and guardian map. |
| `GuardianNotification` / `guardian_notifications` | OK | `entity/GuardianNotification.java` | `GuardianNotification.java:14`, `V9__create_guardian_notifications.sql:2` | Stores text/voice-note notifications. |
| `SosEvent` / `sos_events` | OK | `entity/SosEvent.java` | `SosEvent.java:14`, `V10__create_sos_events.sql:2` | State flow: `TRIGGERED -> ACKNOWLEDGED -> RESOLVED`. |
| `UserSettings` / `user_settings` | OK | `entity/UserSettings.java` | `UserSettings.java:13`, `V11__create_user_settings.sql:2` | TTS, haptic, and warning preferences. |
| `AiConfig` / `ai_configs` | OK | `entity/AiConfig.java` | `AiConfig.java:13`, `V12__create_ai_configs.sql:2` | Guardian-configurable AI threshold and labels. |
| `VoiceCommandConfig` / `voice_command_configs` | OK | `entity/VoiceCommandConfig.java` | `VoiceCommandConfig.java:14`, `V13__create_voice_command_configs.sql:2` | Stores configurable voice commands. |
| `HardwareShortcut` / `hardware_shortcuts` | OK | `entity/HardwareShortcut.java` | `HardwareShortcut.java:14`, `V14__create_hardware_shortcuts.sql:2` | Stores key/button shortcuts. |
| `GeofenceConfig` / `geofence_configs` | OK | `entity/GeofenceConfig.java` | `GeofenceConfig.java:13`, `V15__create_geofence_configs.sql:2` | Stores guardian geofence radius and center. |
| `RefreshToken` / `refresh_tokens` | OK | `entity/RefreshToken.java` | `RefreshToken.java:13`, `V16__create_refresh_tokens.sql:2` | Supports access-token refresh and logout invalidation. |
| `UserRole` enum | RENAMED/MERGED | `entity/User.java` | `User.java:26` | Architecture described `UserRole.java`, but implementation stores role as `String`. See DEV-002. |
### 1.2 Backend Repositories
| Design Repository | Status | Code File | Evidence | Notes |
|---|---|---|---|---|
| `UserRepository` | OK | `repository/UserRepository.java` | `UserRepository.java:9` | Extends `JpaRepository<User, Long>`. |
| `PairingRelationRepository` | OK | `repository/PairingRelationRepository.java` | `PairingRelationRepository.java:10` | Pairing lookup by guardian/user/status. |
| `ActivityLogRepository` | OK | `repository/ActivityLogRepository.java` | `ActivityLogRepository.java:13` | Paged log queries. |
| `ObstacleLogRepository` | OK | `repository/ObstacleLogRepository.java` | `ObstacleLogRepository.java:8` | Paged obstacle history. |
| `LocationHistoryRepository` | OK | `repository/LocationHistoryRepository.java` | `LocationHistoryRepository.java:9` | Last location and history queries. |
| `GuardianNotificationRepository` | OK | `repository/GuardianNotificationRepository.java` | `GuardianNotificationRepository.java:9` | Unread count and notification list. |
| `SosEventRepository` | OK | `repository/SosEventRepository.java` | `SosEventRepository.java:8` | User/guardian SOS history. |
| `UserSettingsRepository` | OK | `repository/UserSettingsRepository.java` | `UserSettingsRepository.java:7` | One settings record per user. |
| `AiConfigRepository` | OK | `repository/AiConfigRepository.java` | `AiConfigRepository.java:7` | One AI config per user. |
| `VoiceCommandConfigRepository` | OK | `repository/VoiceCommandConfigRepository.java` | `VoiceCommandConfigRepository.java:9` | Voice command lookup. |
| `HardwareShortcutRepository` | OK | `repository/HardwareShortcutRepository.java` | `HardwareShortcutRepository.java:7` | Shortcut lookup. |
| `GeofenceConfigRepository` | OK | `repository/GeofenceConfigRepository.java` | `GeofenceConfigRepository.java:7` | Geofence lookup. |
| `RefreshTokenRepository` | OK | `repository/RefreshTokenRepository.java` | `RefreshTokenRepository.java:7` | Refresh token lookup/deletion. |
### 1.3 Backend Services
| Design Service | Status | Code File | Evidence | Notes |
|---|---|---|---|---|
| `AuthService` | OK | `service/AuthService.java` | `AuthService.java:31`, `register:63`, `login:116`, `refreshToken:131` | Handles auth, token refresh, logout, FCM token update. |
| `PairingService` | OK | `service/PairingService.java` | `PairingService.java:19`, `inviteUser:30`, `respondToPairing:71` | Handles invite, respond, unpair, and status. |
| `ActivityLogService` | OK | `service/ActivityLogService.java` | `ActivityLogService.java:18` | Creates and retrieves activity logs. |
| `LocationService` | OK | `service/LocationService.java` | `LocationService.java:24`, `updateLocation:36`, `haversineMeters:115` | Persists GPS updates and checks geofence distance. |
| `ObstacleLogService` | OK | `service/ObstacleLogService.java` | `ObstacleLogService.java:18`, `saveObstacle:24` | Persists obstacle detection events. |
| `NotificationService` | OK | `service/NotificationService.java` | `NotificationService.java:26`, `sendNotification:36` | Sends and reads guardian notifications. |
| `SosService` | OK | `service/SosService.java` | `SosService.java:26`, `triggerSos:38`, `acknowledgeSos:87` | Persists SOS, sends FCM/log notification, broadcasts WebSocket. |
| `AiConfigService` | OK | `service/AiConfigService.java` | `AiConfigService.java:17`, `getConfig:23`, `updateConfigByGuardian:29` | Guardian changes AI config for paired user. |
| `VoiceCommandService` | OK | `service/VoiceCommandService.java` | `VoiceCommandService.java:20` | Reads and updates voice commands. |
| `HardwareShortcutService` | OK | `service/HardwareShortcutService.java` | `HardwareShortcutService.java:15` | Reads and updates hardware shortcuts. |
| `GeofenceService` | OK | `service/GeofenceService.java` | `GeofenceService.java:24` | Reads and updates geofence settings. |
| `UserSettingsService` | OK | `service/UserSettingsService.java` | `UserSettingsService.java:12` | Reads and updates user accessibility settings. |
| `FcmService` | PARTIAL | `service/FcmService.java` | `FcmService.java:17`, `sendToToken:19`, `sendHighPriority:46` | Currently log-only until Firebase Admin credentials are configured. See DEV-004. |
| `AgoraTokenService` | PARTIAL | `service/AgoraTokenService.java` | `AgoraTokenService.java:34` | Token API exists; live token depends on Agora app id/certificate. |
| `GuardianDashboardService` | OK | `service/GuardianDashboardService.java` | `GuardianDashboardService.java:13`, `getDashboard:22` | Facade for guardian dashboard aggregate data. |
| `MockDataService` | ADDED | `service/MockDataService.java` | `MockDataService.java` | Supports tests/demo data; not part of original domain design. |
| `ObstacleAlertStrategyService` | MISSING | - | `rg "ObstacleAlertStrategyService"` returns none | Architecture mentioned backend alert strategy, but current alert strategy is implemented on Flutter side. See DEV-005. |
### 1.4 Backend Controllers and API Entry Points
| Design Controller | Status | Code File | Evidence | Notes |
|---|---|---|---|---|
| `AuthController` | OK | `controller/AuthController.java` | `@RequestMapping:14`, `login:32`, `register:26` | `/api/v1/auth` routes. |
| `PairingController` | OK | `controller/PairingController.java` | `@RequestMapping:13`, `invite:19`, `respond:28` | `/api/v1/shared/pairing` routes. |
| `UserController` | OK | `controller/UserController.java` | `@RequestMapping:19`, `logObstacle:101`, `triggerSos:109` | `/api/v1/user` routes. |
| `GuardianController` | OK | `controller/GuardianController.java` | `@RequestMapping:16`, `dashboard:32`, `sendNotif:77` | `/api/v1/guardian` routes. |
| `CallController` | OK | `controller/CallController.java` | `@RequestMapping:38`, `token:55`, `notify:75`, `end:127` | `/api/v1/shared/call` routes. |
| `GET /guardian/user-status` | RENAMED/MERGED | `GuardianController.dashboard()` | `GuardianController.java:32` | The explicit architecture endpoint is not present; status data is covered by dashboard and pairing/status. See DEV-006. |
### 1.5 Backend Security and Infrastructure
| Design Class | Status | Code File | Evidence | Notes |
|---|---|---|---|---|
| `SecurityConfig` | OK | `config/SecurityConfig.java` | `SecurityConfig.java:23`, `filterChain:33` | RBAC routes for auth, guardian, user, shared. |
| `JwtAuthFilter` | OK | `security/JwtAuthFilter.java` | `JwtAuthFilter.java:19`, `doFilterInternal:24` | Chain of Responsibility filter before controller access. |
| `JwtUtil` | OK | `security/JwtUtil.java` | `JwtUtil.java:19` | Token generation and validation. |
| `SecurityHelper` | ADDED | `security/SecurityHelper.java` | `SecurityHelper.java:11` | Helper to get current authenticated user id. |
| `WebSocketConfig` | OK | `config/WebSocketConfig.java` | `WebSocketConfig.java:25` | STOMP broker setup. |
| `LocationBroadcaster` | OK | `websocket/LocationBroadcaster.java` | `LocationBroadcaster.java:25`, `broadcastLocation:37`, `broadcastSos:51` | Observer-style real-time broadcast. |
| `OpenApiConfig` | OK | `config/OpenApiConfig.java` | `OpenApiConfig.java:12` | Swagger/OpenAPI config. |
| `GlobalExceptionHandler` | OK | `exception/GlobalExceptionHandler.java` | `GlobalExceptionHandler.java:11` | Central error handling. |
| `DataSeeder` | OK | `config/DataSeeder.java` | `DataSeeder.java:12` | Seeds default data if needed. |
| `CustomUserDetailsService` | MISSING | - | `rg "CustomUserDetailsService"` returns none | JWT auth stores user id in authentication credentials directly. No separate `UserDetailsService` was required. |
### 1.6 Flutter Core, AI, and Services
| Design Class | Status | Code File | Evidence | Notes |
|---|---|---|---|---|
| `ApiClient` | OK | `lib/core/network/api_client.dart` | `ApiClient:5`, `init:11`, interceptors `22-26` | Dynamic base URL and Dio interceptor chain. |
| `_AuthInterceptor` | OK | `lib/core/network/api_client.dart` | `_AuthInterceptor:37`, `onRequest:45`, `onError:54` | Injects JWT and refreshes token on 401. |
| `_ErrorInterceptor` | OK | `lib/core/network/api_client.dart` | `_ErrorInterceptor:95` | Error pass-through/normalization boundary. |
| `SecureStorage` | OK | `lib/core/storage/secure_storage.dart` | `SecureStorage:5` | Stores token, role, user id, display name. |
| `TtsService` | OK | `lib/core/services/tts_service.dart` | `TtsService:3` | Text-to-speech service. |
| `SttService` | OK | `lib/core/services/stt_service.dart` | `SttService:5` | Speech-to-text service. |
| `HapticService` | OK | `lib/core/services/haptic_service.dart` | `HapticService:3` | Haptic feedback wrapper. |
| `VoiceCommandHandler` | OK | `lib/core/services/voice_command_handler.dart` | `VoiceCommandHandler:23`, `_processText:56`, `_handleCommand:67` | Facade for STT result matching and TTS commands. |
| `WebSocketService` | OK | `lib/core/services/websocket_service.dart` | `WebSocketService:19`, `subscribeLocation:98`, `subscribeSos:127`, `subscribeNotification:152` | Observer subscriptions for live events. |
| `FcmService` | PARTIAL | `lib/core/services/fcm_service.dart` | `FcmService:7`, `put /auth/fcm-token:37` | Client token registration exists; live push depends on backend Firebase credentials. |
| `LocationReporterService` | OK | `lib/core/services/location_reporter_service.dart` | `LocationReporterService:10`, `_sendOnce:31` | Sends periodic GPS updates; uses offline queue fallback. |
| `OfflineQueueService` | ADDED | `lib/core/services/offline_queue_service.dart` | `OfflineQueueService:35` | Pragmatic SharedPreferences queue; not full Drift implementation. |
| `CallService` | OK | `lib/core/services/call_service.dart` | `CallService:7`, `/shared/call/notify:36` | Backend call notify integration. |
| `ObstacleAnalyzer` | OK | `lib/core/ai/obstacle_analyzer.dart` | `ObstacleAnalyzer:46`, `analyzeDirection:50`, `estimateDistance:57` | Strategy-like obstacle interpretation logic. |
| `YoloDetector` | OK | `lib/core/ai/yolo_detector.dart` | `YoloDetector:11`, `init:27`, `detect:55`, `_decodeDetections:243` | Runs camera input through YOLO runtime on native targets. |
| `YoloRuntime` | ADDED | `lib/core/ai/yolo_runtime.dart` | `export:1` | Conditional export for native TFLite vs Web stub. |
| `YoloRuntimeNative` | ADDED | `lib/core/ai/yolo_runtime_native.dart` | `YoloRuntime:7`, `Interpreter.fromAsset:27` | Native TFLite runtime. |
| `YoloRuntimeStub` | ADDED | `lib/core/ai/yolo_runtime_stub.dart` | `YoloRuntime:5` | Web-safe fallback; avoids `dart:ffi` compile error. See DEV-001. |
### 1.7 Flutter Feature Screens and State Management
| Design Element | Status | Code File | Evidence | Notes |
|---|---|---|---|---|
| `WalkGuideApp` | OK | `lib/app/app.dart` | `WalkGuideApp:8`, `BlocProvider:15` | Root app provides `AppCubit`. |
| `AppCubit` | OK | `lib/app/app_cubit.dart` | `AppCubit:19`, `emit:23` | BLoC/Cubit state management is present. |
| `Router` | OK | `lib/app/router.dart` | `router.dart` | GoRouter route definitions. |
| `LoginScreen` | OK | `features/auth/login_screen.dart` | `_login:62`, `POST /auth/login:69` | Auth UI flow. |
| `RegisterScreen` | OK | `features/auth/register_screen.dart` | `_register:47`, `POST /auth/register:56` | Registration UI flow. |
| `UserPairingScreen` | OK | `features/pairing/pairing_screens.dart` | `UserPairingScreen:22`, respond:250 | User pairing response UI. |
| `GuardianPairingScreen` | OK | `features/pairing/pairing_screens.dart` | `GuardianPairingScreen:90`, invite:110 | Guardian invite UI. |
| `WalkGuideScreen` | OK | `features/walk_guide/walk_guide_screen.dart` | `WalkGuideScreen:24`, stream:87, detect:121, log:148 | Live camera + YOLO integration. |
| `AiBenchmarkScreen` | OK | `features/walk_guide/walk_guide_screen.dart` | `AiBenchmarkScreen:244`, `detectSynthetic:299` | AI benchmark/demo path. |
| `SosScreen` | OK | `features/sos/sos_screen.dart` | `_sendSos:179`, `POST /user/sos:183` | User SOS flow. |
| `NotificationScreen` | OK | `features/notifications/notification_screen.dart` | `_load:34`, `GET /user/notifications:41` | User notification flow. |
| `GuardianDashboardScreen` | OK | `features/home/presentation/guardian_dashboard_screen.dart` | `_loadAll:85`, `GET /guardian/dashboard:181` | Guardian dashboard. |
| `GuardianAiConfigScreen` | OK | `features/guardian_dashboard/guardian_ai_config_screen.dart` | `_load:42`, `_save:86` | Guardian AI config UI. |
| `ActivityLogScreen` | OK | `features/activity_log/activity_log_screen.dart` | `_load:42`, `GET /user/activity-logs:49` | User activity logs. |
| `NavigationModeScreen` | OK | `features/navigation_mode/navigation_mode_screen.dart` | `POST /user/location:95` | Navigation/location mode. |
| Strict feature-first Clean Architecture | PARTIAL | `features/auth/*`, several direct screen files | `auth/data`, `auth/domain`, `auth/presentation`; direct calls in feature screens | Auth follows domain/data split. Several features are pragmatic screen-first with direct API calls. See DEV-003. |
| BLoC-only state management | PARTIAL | `app/app_cubit.dart`, multiple `StatefulWidget` screens | `AppCubit:19`, many screens use local `setState` | Cubit is used globally; feature state is mostly local `StatefulWidget`. See DEV-003. |
---
## 2. Design Pattern Audit
### 2.1 Builder Pattern - Creational
| Audit Field | Evidence |
|---|---|
| Design Intent | Create complex entity/DTO objects without long constructors. |
| Implementation | Lombok `@Builder` on entities and response DTOs. |
| Files | `entity/User.java:10`, `entity/AiConfig.java:10`, `dto/response/AuthDataResponse.java:6`, `dto/response/DashboardResponse.java:11`, `dto/response/SosEventResponse.java:15`. |
| Client Usage | Services return `DashboardResponse.builder()...build()` in `GuardianDashboardService.java:27` and `:44`; SOS builds `SosEvent.builder()` in `SosService.java:39`. |
| Verdict | OK. Builder is correctly used across backend entity/response construction. |
### 2.2 Singleton / Service Locator - Creational
| Audit Field | Evidence |
|---|---|
| Design Intent | Resource-heavy app services should have one shared lifecycle. |
| Implementation | GetIt `registerLazySingleton` in Flutter DI. |
| Files | `lib/app/injection_container.dart:21-35`. |
| Registered Singletons | `SecureStorage`, `ApiClient`, `TtsService`, `SttService`, `HapticService`, `ObstacleAnalyzer`, `YoloDetector`, `FcmService`, `WebSocketService`, `LocationReporterService`, `CallService`, `VoiceCommandHandler`. |
| Verdict | OK. Services are resolved from DI instead of constructed repeatedly in screens. |
### 2.3 Facade Pattern - Structural
| Audit Field | Evidence |
|---|---|
| Design Intent | Hide multiple lower-level service calls behind a simpler feature-facing API. |
| Backend Implementation | `GuardianDashboardService.getDashboard()` aggregates pairing, location, activity logs, SOS count, and notification count. |
| Backend Evidence | `GuardianDashboardService.java:13`, `getDashboard:22`, location call at `:37`, activity call at `:38`, counts at `:42-43`. |
| Flutter Implementation | `VoiceCommandHandler` hides STT result matching and TTS-only commands behind `loadCommands`, `loadDefaultCommands`, and callback dispatch. |
| Flutter Evidence | `voice_command_handler.dart:23`, `_processText:56`, `_handleCommand:67`. |
| Verdict | OK. Both implementations reduce caller complexity. |
### 2.4 Repository Pattern - Structural
| Audit Field | Evidence |
|---|---|
| Design Intent | Service layer depends on persistence contracts, not raw SQL. |
| Backend Implementation | Spring Data `JpaRepository` interfaces. |
| Files | `UserRepository.java:9`, `PairingRelationRepository.java:10`, `ActivityLogRepository.java:13`, `ObstacleLogRepository.java:8`, and other repositories under `repository/`. |
| Flutter Implementation | Auth feature has `AuthRepository` and `AuthRepositoryImpl`. |
| Flutter Evidence | `features/auth/domain/auth_repository.dart:5`, `features/auth/data/auth_repository_impl.dart:8`. |
| Verdict | OK for backend and Auth feature. PARTIAL for other Flutter features that still call `ApiClient` directly from screens. See DEV-003. |
### 2.5 Observer Pattern - Behavioral
| Audit Field | Evidence |
|---|---|
| Design Intent | Subscribers receive state/event updates without polling. |
| Flutter Cubit | `AppCubit` emits state; `WalkGuideApp` provides it via `BlocProvider`. |
| Flutter Evidence | `app_cubit.dart:19`, `emit:23`, `app.dart:15`. |
| WebSocket Subject | Backend `LocationBroadcaster` pushes to STOMP destinations. |
| Backend Evidence | `LocationBroadcaster.java:37` for `/topic/location/{userId}`, `:51` for `/queue/sos/{guardianId}`, `:65` for `/queue/notif/{userId}`. |
| Flutter Observers | `WebSocketService.subscribeLocation`, `subscribeSos`, `subscribeNotification`. |
| Flutter Evidence | `websocket_service.dart:98`, `:127`, `:152`. |
| Verdict | OK. Observer behavior exists in both UI state and real-time messaging. |
### 2.6 Strategy Pattern - Behavioral
| Audit Field | Evidence |
|---|---|
| Design Intent | Obstacle interpretation should be isolated from camera/model runtime. |
| Implementation | `ObstacleAnalyzer` encapsulates direction, distance, priority, and confidence filtering rules. |
| Files | `obstacle_analyzer.dart:46`, `analyzeDirection:50`, `estimateDistance:57`, `prioritize:75`, `filterByConfidence:98`. |
| Client Usage | `YoloDetector` delegates post-processing interpretation to analyzer at `yolo_detector.dart:66-67` and `:485-486`. |
| Verdict | PARTIAL. Strategy-like separation exists in Flutter, but the architecture's backend `ObstacleAlertStrategyService` is not implemented. See DEV-005. |
### 2.7 Chain of Responsibility - Behavioral
| Audit Field | Evidence |
|---|---|
| Design Intent | Requests pass through ordered handlers for authentication, refresh, and error handling. |
| Flutter Implementation | Dio interceptors are registered in order: `_AuthInterceptor`, `_ErrorInterceptor`, `LogInterceptor`. |
| Flutter Evidence | `api_client.dart:22-26`, `_AuthInterceptor:37`, `onRequest:45`, `onError:54`, `_ErrorInterceptor:95`. |
| Backend Implementation | Spring Security filter chain inserts `JwtAuthFilter` before `UsernamePasswordAuthenticationFilter`. |
| Backend Evidence | `SecurityConfig.java:33`, `.addFilterBefore(...):53`, `JwtAuthFilter.java:24`. |
| Verdict | OK. Request processing is ordered and composable. |
---
## 3. Sequence Diagram Trace
### 3.1 UC-01 Login and Route to Role Dashboard
Designed flow: User -> Login UI -> Flutter API client -> AuthController -> AuthService -> UserRepository/JwtUtil -> Flutter storage/router.
| # | Diagram Step | Actual Code Trace | Status |
|---|---|---|---|
| 1 | User submits credentials | `LoginScreen._login()` at `features/auth/login_screen.dart:62` | OK |
| 2 | Flutter calls backend login | `dio.post('/auth/login')` at `features/auth/login_screen.dart:69` | OK |
| 3 | Backend receives request | `AuthController.login()` at `AuthController.java:32` | OK |
| 4 | Controller delegates service | `authService.login(req)` at `AuthController.java:35` | OK |
| 5 | Service validates user/password | `AuthService.login()` at `AuthService.java:116` | OK |
| 6 | Service returns token response | `AuthDataResponse.builder()` in `AuthService.java` | OK |
| 7 | Flutter stores tokens and routes | `_saveAuthAndRoute()` at `features/auth/login_screen.dart:195` | OK |
| 8 | App state set | `AppCubit.loadSession()` / `emit()` at `app_cubit.dart:23` | OK |
### 3.2 UC-02 Guardian Invites User for Pairing
Designed flow: Guardian -> Pairing UI -> PairingController -> PairingService -> repositories -> FCM/log notification -> response.
| # | Diagram Step | Actual Code Trace | Status |
|---|---|---|---|
| 1 | Guardian enters unique user id | `GuardianPairingScreen` at `pairing_screens.dart:90` | OK |
| 2 | Flutter sends invite | `dio.post('/shared/pairing/invite')` at `pairing_screens.dart:110` | OK |
| 3 | Backend receives invite | `PairingController.invite()` at `PairingController.java:19` | OK |
| 4 | Service validates and creates relation | `PairingService.inviteUser()` at `PairingService.java:30` | OK |
| 5 | Service sends notification | `fcmService.sendToToken(...)` at `PairingService.java:59` | PARTIAL - FCM is log-only until credentials are configured |
| 6 | User accepts/rejects | `dio.post('/shared/pairing/respond')` at `pairing_screens.dart:250` -> `PairingController.respond()` at `PairingController.java:28` | OK |
| 7 | Service updates status | `PairingService.respondToPairing()` at `PairingService.java:71` | OK |
### 3.3 UC-03 Start WalkGuide and Log Obstacle
Designed flow: User -> WalkGuide UI -> Camera stream -> YOLO detector -> ObstacleAnalyzer -> TTS/Haptic -> API -> UserController -> ObstacleLogService -> database.
| # | Diagram Step | Actual Code Trace | Status |
|---|---|---|---|
| 1 | User starts WalkGuide | `_toggle()` at `walk_guide_screen.dart:51` | OK |
| 2 | Camera image stream starts | `controller.startImageStream(_onCameraImage)` at `walk_guide_screen.dart:87` | OK |
| 3 | Frame callback triggered | `_onCameraImage()` at `walk_guide_screen.dart:109` | OK |
| 4 | Run YOLO detection | `sl<YoloDetector>().detect(image)` at `walk_guide_screen.dart:121` | OK on native/mobile |
| 5 | Native TFLite runtime loads model | `Interpreter.fromAsset()` at `yolo_runtime_native.dart:27` | OK on native/mobile |
| 6 | Decode model output | `_decodeDetections()` at `yolo_detector.dart:243` | OK |
| 7 | Analyze direction/distance | `analyzeDirection()` and `estimateDistance()` at `obstacle_analyzer.dart:50` and `:57` | OK |
| 8 | Log obstacle to backend | `dio.post('/user/obstacle')` at `walk_guide_screen.dart:148` | OK |
| 9 | Backend receives obstacle | `UserController.logObstacle()` at `UserController.java:101` | OK |
| 10 | Backend persists obstacle | `ObstacleLogService.saveObstacle()` at `ObstacleLogService.java:24` | OK |
| 11 | TTS alert | `TtsService.speakImmediate()` at `walk_guide_screen.dart:158` | OK |
Important platform note: on Chrome/Web, `YoloRuntimeStub` is selected and the app falls back to a demo detection. This is why the Web demo can show `person CENTER Close` repeatedly. Real YOLO runs only on Android/native. See DEV-001.
### 3.4 UC-04 User Sends SOS and Guardian Receives Alert
Designed flow: User -> SOS UI -> UserController -> SosService -> SosEventRepository -> FCM + WebSocket -> Guardian dashboard.
| # | Diagram Step | Actual Code Trace | Status |
|---|---|---|---|
| 1 | User presses SOS | `SosScreen._sendSos()` at `sos_screen.dart:179` | OK |
| 2 | Flutter posts SOS | `dio.post('/user/sos')` at `sos_screen.dart:183` | OK |
| 3 | Backend receives request | `UserController.triggerSos()` at `UserController.java:109` | OK |
| 4 | Service creates SOS event | `SosService.triggerSos()` at `SosService.java:38` | OK |
| 5 | SOS persisted | `sosEventRepository.save(sos)` in `SosService.java:46` | OK |
| 6 | Activity log created | `activityLogService.createLog(...)` in `SosService.java:52` | OK |
| 7 | Guardian notified by FCM | `fcmService.sendHighPriority(...)` in `SosService.java:67` | PARTIAL - log-only FCM |
| 8 | Guardian notified by WebSocket | `locationBroadcaster.broadcastSos(...)` in `SosService.java:79` | OK |
| 9 | Guardian acknowledges | `GuardianController.acknowledgeSos()` at `GuardianController.java:94` -> `SosService.acknowledgeSos()` at `SosService.java:87` | OK |
### 3.5 UC-05 Guardian Updates AI Configuration
Designed flow: Guardian -> AI Config UI -> GuardianController -> AiConfigService -> repository -> FCM/WebSocket notification -> User app reads latest config.
| # | Diagram Step | Actual Code Trace | Status |
|---|---|---|---|
| 1 | Guardian opens config | `GuardianAiConfigScreen._load()` at `guardian_ai_config_screen.dart:42` | OK |
| 2 | Flutter fetches config | `GET /guardian/ai-config` at `guardian_ai_config_screen.dart:59` | OK |
| 3 | Guardian saves config | `_save()` at `guardian_ai_config_screen.dart:86` | OK |
| 4 | Flutter sends update | `PUT /guardian/ai-config` at `guardian_ai_config_screen.dart:89` | OK |
| 5 | Backend receives update | `GuardianController.updateAiConfig()` at `GuardianController.java:109` | OK |
| 6 | Service validates pairing and saves | `AiConfigService.updateConfigByGuardian()` at `AiConfigService.java:29` | OK |
| 7 | User notified | `fcmService.sendToToken(...)` at `AiConfigService.java:48` | PARTIAL - log-only FCM |
---
## 4. Design Deviation Log
### DEV-001 - YOLO Native Runtime vs Web Fallback
| Field | Content |
|---|---|
| Type | Platform / AI runtime |
| Design Element | In-device YOLOv8n object detection |
| Design Intent | Camera frame is processed by YOLO/TFLite and shows real detected obstacle. |
| Actual Implementation | Native/mobile uses `YoloRuntimeNative` and `tflite_flutter`; Web uses `YoloRuntimeStub` through conditional export at `yolo_runtime.dart:1`. |
| Rationale | `tflite_flutter` depends on `dart:ffi`, which cannot compile for Chrome/Web. Conditional runtime keeps Web demo buildable while preserving Android YOLO. |
| Impact | Chrome/Web may repeatedly show fallback `person CENTER Close (1-2m)`. Real AI must be demonstrated on Android/emulator/physical device. |
| Re-alignment Required? | Yes. Add platform note to architecture and presentation script. |
### DEV-002 - `UserRole` Enum Not Implemented
| Field | Content |
|---|---|
| Type | Class / type safety |
| Design Element | `UserRole.java` enum in architecture document |
| Design Intent | Use enum values `ROLE_GUARDIAN` and `ROLE_USER`. |
| Actual Implementation | `User.role` is a `String` at `User.java:26`; services compare string literals such as `ROLE_USER` and `ROLE_GUARDIAN`. |
| Rationale | Simpler compatibility with existing JWT role strings and seeded data. |
| Impact | Lower type safety; role typo would be caught at runtime rather than compile time. |
| Re-alignment Required? | Preferred. Either add `UserRole` enum or update class diagram to reflect `String role`. |
### DEV-003 - Flutter Clean Architecture Applied Partially
| Field | Content |
|---|---|
| Type | Architecture / layering |
| Design Element | Strict feature-first Clean Architecture with `domain/data/application/presentation` for every feature |
| Design Intent | All features have clean repository/use-case/presentation boundaries and use BLoC consistently. |
| Actual Implementation | Auth feature has domain/data split (`AuthRepository`, `AuthRepositoryImpl`, `AuthRemoteDataSource`), but several features call `ApiClient` directly from `StatefulWidget` screens. `features/screens.dart` also keeps a monolithic compatibility screen set. |
| Rationale | Deadline-driven integration prioritized complete working flows and demo readiness. |
| Impact | Some UI classes contain API orchestration and `try/catch`; this should be disclosed and improved if strict grading focuses on Flutter architecture. |
| Re-alignment Required? | Yes for ideal final refactor. For presentation, document as pragmatic deviation. |
### DEV-004 - FCM Backend Is Log-Only
| Field | Content |
|---|---|
| Type | External service integration |
| Design Element | Firebase Admin SDK sends real background push notification. |
| Design Intent | Backend sends FCM push for SOS, incoming call, pairing, settings updates. |
| Actual Implementation | `FcmService.sendToToken()` logs payload and contains commented Firebase implementation at `FcmService.java:19-43`; `sendHighPriority()` delegates at `:46-48`. |
| Rationale | Firebase Admin credentials are not committed and may not be available for local demo. |
| Impact | Foreground WebSocket and UI flows work; background push requires credentials before live demo. |
| Re-alignment Required? | Yes if lecturer asks for real push notification evidence. |
### DEV-005 - Backend `ObstacleAlertStrategyService` Missing
| Field | Content |
|---|---|
| Type | Pattern / service boundary |
| Design Element | Backend Strategy pattern for obstacle alert modes |
| Design Intent | Backend strategy chooses TTS/haptic alert behavior based on AI config. |
| Actual Implementation | Alert behavior is handled on-device. `ObstacleAnalyzer` performs direction/distance/priority logic, and `WalkGuideScreen` triggers TTS at `walk_guide_screen.dart:158`. No backend `ObstacleAlertStrategyService` exists. |
| Rationale | Obstacle warning must happen locally and fast, even offline. Backend only stores obstacle logs and settings. |
| Impact | Pattern is implemented in Flutter, not backend. Architecture diagram should move this responsibility to mobile. |
| Re-alignment Required? | Yes. Update design pattern documentation to show Flutter-side strategy. |
### DEV-006 - `/guardian/user-status` Endpoint Merged into Dashboard/Status APIs
| Field | Content |
|---|---|
| Type | Interface / API contract |
| Design Element | `GET /api/v1/guardian/user-status` from architecture endpoint list |
| Design Intent | Guardian fetches paired user status from a dedicated endpoint. |
| Actual Implementation | `GuardianController` provides `/dashboard`, `/user-location`, `/location-history`, `/activity-logs`, `/obstacle-logs`, and pairing status via `/shared/pairing/status`. No direct `/guardian/user-status` route exists. |
| Rationale | Dashboard endpoint already aggregates paired user data, reducing duplicate endpoint surface. |
| Impact | Functional data is available, but OpenAPI/architecture endpoint list should be updated. |
| Re-alignment Required? | Yes. Update endpoint table or add thin alias endpoint. |
### DEV-007 - Local Database/Offline Design Simplified
| Field | Content |
|---|---|
| Type | Persistence / offline-first |
| Design Element | Drift/SQLite ORM cache for offline-first synchronization |
| Design Intent | Persist offline records locally and sync when online. |
| Actual Implementation | `OfflineQueueService` exists and uses a pragmatic queue, while many features still call API directly. |
| Rationale | Narrowed implementation to support reliable demo paths first. |
| Impact | Offline-first is partial, not full production-grade local cache. |
| Re-alignment Required? | Yes if offline-first is claimed as a complete advanced feature. |
### DEV-008 - Development Database Credentials Present as Defaults
| Field | Content |
|---|---|
| Type | Configuration / security |
| Design Element | No hardcoded secrets; environment-separated configs |
| Design Intent | Use environment variables for DB credentials. |
| Actual Implementation | `application-dev.yml` uses env placeholders with fallback values for the university PostgreSQL database. |
| Rationale | Internal exam database credentials were provided for local/demo usage and kept as fallbacks. |
| Impact | Acceptable for private/internal submission, but risky for public repository. |
| Re-alignment Required? | Before public upload, remove fallback password and rely on `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`. |
---
## 5. Cross-Pillar Consistency
### 5.1 OOAD Class Diagram vs Backend Entities
| Check | Result |
|---|---|
| Core domain entities exist as Java classes | OK |
| Core domain entities exist as Flyway tables | OK |
| Pairing relationship represented in code and DB | OK |
| SOS state represented in enum and service flow | OK |
| User role modeled | PARTIAL - implemented as string instead of enum |
### 5.2 ERD vs Database Migrations
| Table Group | Migration Evidence | Result |
|---|---|---|
| Auth/User | `V1__create_users_table.sql`, `V16__create_refresh_tokens.sql` | OK |
| Pairing | `V5__create_pairing_relations.sql` | OK |
| WalkGuide logs | `V6__create_activity_logs.sql`, `V7__create_obstacle_logs.sql`, `V8__create_location_history.sql` | OK |
| Notifications/SOS | `V9__create_guardian_notifications.sql`, `V10__create_sos_events.sql` | OK |
| Settings/configuration | `V11__create_user_settings.sql`, `V12__create_ai_configs.sql`, `V13__create_voice_command_configs.sql`, `V14__create_hardware_shortcuts.sql`, `V15__create_geofence_configs.sql` | OK |
### 5.3 Architecture vs Flutter Implementation
| Requirement | Evidence | Result |
|---|---|---|
| BLoC/Cubit exists | `AppCubit` and `BlocProvider` | OK |
| BLoC-only across all features | Multiple local `StatefulWidget` states | PARTIAL |
| GoRouter routing | `lib/app/router.dart` | OK |
| Dynamic server URL | `AppConstants.getServerUrl()` and `ApiClient.init()` | OK |
| JWT auth and refresh | `_AuthInterceptor.onError()` refresh path | OK |
| YOLO model asset | `assets/models/yolov8n.tflite` included in APK; APK contains `assets/flutter_assets/assets/models/yolov8n.tflite` | OK |
| Web build compatibility | `YoloRuntimeStub` conditional import | OK |
| Real YOLO on Chrome | Not supported by `dart:ffi` | NOT APPLICABLE - native only |
### 5.4 API Contract vs Controllers
| API Area | Controller Evidence | Result |
|---|---|---|
| Auth | `AuthController.java:14-52` | OK |
| Pairing | `PairingController.java:13-44` | OK |
| User APIs | `UserController.java:19-174` | OK |
| Guardian APIs | `GuardianController.java:16-162` | OK, except `/guardian/user-status` merged |
| Shared Call APIs | `CallController.java:38-129` | OK |
| Response envelope | `ApiResponse.java:5` used by controllers | OK |
| OpenAPI file | `src/main/resources/openapi.yaml` | OK |
### 5.5 Design Pattern UML vs Code Alignment
This section maps each submitted PlantUML pattern diagram to the current codebase. It exists because Phase 1B requires every design pattern to be documented with UML and Phase 1C requires each pattern claim to be traceable to source code.
| # | Pattern | UML File | UML Intent | Code Evidence | Alignment | Audit Notes |
|---|---|---|---|---|---|---|
| 1 | Builder | `ooad-docs/01_Builder_Pattern.puml` | `User.builder()`, `AuthDataResponse.builder()`, and Firebase `Message.builder()` build complex objects. | `User.java:10`, `AiConfig.java:10`, `AuthDataResponse.java:6`, `DashboardResponse.java:11`, `SosService.java:39`, `GuardianDashboardService.java:27` | PARTIAL | Lombok builders are implemented. Firebase `Message.builder()` is still commented/log-only in `FcmService.java:31-39`; `Role` is implemented as `String`, not enum. |
| 2 | Singleton | `ooad-docs/02_Singleton_Pattern.puml` | GetIt manages single app-wide instances for TTS, STT, YOLO, WebSocket, Agora/call services. | `injection_container.dart:21-35`, `YoloDetector.dart:11`, `TtsService.dart:3`, `WebSocketService.dart:19`, `CallService.dart:7` | PARTIAL | Runtime behavior is singleton/service-locator through GetIt. UML shows static `_instance` fields and `registerSingleton`, but code uses `registerLazySingleton` and no static singleton fields. |
| 3 | Facade | `ooad-docs/03_Facade_Pattern.puml` | `VoiceCommandHandler` and `GuardianDashboardService` hide subsystem complexity. | `voice_command_handler.dart:23`, `_processText:56`, `_handleCommand:67`, `GuardianDashboardService.java:13`, `getDashboard:22` | PARTIAL | Backend facade aligns strongly. Flutter facade exists, but UML includes `GoRouter`, `WalkGuideBloc`, `SosBloc`, and `NotificationBloc`; current code uses STT/TTS plus callback dispatch instead. |
| 4 | Repository / Proxy | `ooad-docs/04_Repository_Proxy_Pattern.puml` | Repository interfaces proxy remote/local data sources and hide online/offline selection. | Backend: `UserRepository.java:9`, `ObstacleLogRepository.java:8`; Flutter Auth: `auth_repository.dart:5`, `auth_repository_impl.dart:8`; Offline queue: `offline_queue_service.dart:35` | PARTIAL | Backend repository pattern is complete. Flutter repository/proxy exists for Auth only; diagrammed `WalkGuideRepositoryImpl`, `ActivityLogRepositoryImpl`, Drift local data sources are not implemented as shown. |
| 5 | Observer | `ooad-docs/05_Observer_Pattern.puml` | BLoC stream and WebSocket subscribers receive updates from subjects. | `app_cubit.dart:19`, `app.dart:15`, `LocationBroadcaster.java:37`, `:51`, `:65`, `websocket_service.dart:98`, `:127`, `:152` | PARTIAL | Observer behavior is present via Cubit and STOMP WebSocket. UML shows `WalkGuideBloc`, `BlocBuilder`, and `BlocListener`; current WalkGuide UI mostly uses `StatefulWidget` local state. |
| 6 | Strategy | `ooad-docs/06_Strategy_Pattern.puml` | `ObstacleAlertStrategy` has concrete alert strategies selected by AI config. | `obstacle_analyzer.dart:46`, `analyzeDirection:50`, `estimateDistance:57`, `prioritize:75`, `walk_guide_screen.dart:158` | PARTIAL | Strategy-like separation exists in `ObstacleAnalyzer`, but the UML's `ObstacleAlertStrategy`, `TtsOnlyStrategy`, `TtsWithHapticStrategy`, and `HapticOnlyStrategy` classes are not present. Logged as DEV-005. |
| 7 | Chain of Responsibility | `ooad-docs/07_ChainOfResponsibility_Pattern.puml` | HTTP requests pass through Flutter Dio interceptors and backend Spring Security filters. | `api_client.dart:22-26`, `_AuthInterceptor:37`, `_ErrorInterceptor:95`, `SecurityConfig.java:53`, `JwtAuthFilter.java:24` | PARTIAL | Chain exists. UML says `ErrorInterceptor` maps HTTP errors to typed `Failure` and `JwtAuthFilter` uses `UserDetailsService`; current code passes errors through and uses `JwtUtil` directly. |
Alignment summary:
| Alignment Type | Count | Patterns |
|---|---:|---|
| Fully aligned with implementation intent | 0 | None are exact 1:1 because the UML still contains planned classes/method names that changed during implementation. |
| Partially aligned and defensible | 7 | Builder, Singleton, Facade, Repository/Proxy, Observer, Strategy, Chain of Responsibility. |
| Completely missing | 0 | Every pattern has at least one implementation counterpart, but several diagrams require update before final report export. |
Required UML updates:
| Action ID | UML File | Required Update |
|---|---|---|
| UML-ACT-01 | `01_Builder_Pattern.puml` | Replace `Role` enum with `String role` or implement `UserRole`; mark Firebase `Message.builder()` as planned/log-only unless FCM credentials are configured. |
| UML-ACT-02 | `02_Singleton_Pattern.puml` | Replace static `_instance` fields with GetIt `registerLazySingleton` lifecycle. |
| UML-ACT-03 | `03_Facade_Pattern.puml` | Update `VoiceCommandHandler` dependencies to STT, TTS, command list, and callback; remove non-existing BLoC dependencies. |
| UML-ACT-04 | `04_Repository_Proxy_Pattern.puml` | Either implement WalkGuide/Activity repositories or mark the diagram as target architecture and show current backend repository + Auth repository only. |
| UML-ACT-05 | `05_Observer_Pattern.puml` | Replace `WalkGuideBloc` with `AppCubit` plus `WebSocketService` subscriptions, or implement the missing WalkGuide BLoC. |
| UML-ACT-06 | `06_Strategy_Pattern.puml` | Replace concrete alert strategy classes with current `ObstacleAnalyzer` strategy-like methods, or implement the missing strategy classes. |
| UML-ACT-07 | `07_ChainOfResponsibility_Pattern.puml` | Update `JwtAuthFilter` dependency from `UserDetailsService` to `JwtUtil`; update `ErrorInterceptor` behavior to pass-through unless typed failure mapping is implemented. |
---
## 6. Audit Summary Dashboard
| Metric | Count / Status | Notes |
|---|---|---|
| Backend core entity classes audited | 13 | All implemented |
| Backend repository interfaces audited | 13 | All implemented |
| Backend service classes audited | 16 actual + 1 missing design service | `ObstacleAlertStrategyService` missing by design shift |
| Backend controllers audited | 5 | All implemented |
| Flutter core service/AI classes audited | 17 | Includes conditional YOLO runtime files |
| Flutter feature screen groups audited | 10+ | All major user/guardian flows present |
| GoF patterns audited | 7 | Builder, Singleton, Facade, Repository, Observer, Strategy, Chain of Responsibility |
| Patterns fully implemented | 5 | Builder, Singleton, Facade, Observer, Chain of Responsibility |
| Patterns partially implemented | 2 | Repository in Flutter partial; Strategy moved to Flutter/mobile |
| Design pattern UML files aligned to code | 7 partial | All 7 UML diagrams have implementation counterparts, but each needs a small update for exact final-code alignment |
| Sequence flows traced | 5 | Login, Pairing, WalkGuide obstacle, SOS, AI Config |
| Documented deviations | 8 | All have rationale and action |
| Overall verdict | CONDITIONAL PASS | Working system; update diagrams/report for documented deviations |
### Overall Audit Verdict
The WalkGuide codebase is functionally ready for demonstration. The backend follows a layered Spring Boot architecture with PostgreSQL/Flyway, JWT/RBAC, controllers, services, repositories, OpenAPI, and test coverage. The Flutter app has working authentication, pairing, WalkGuide camera/YOLO native detection, SOS, notifications, guardian dashboard, map/location, settings, and AI benchmark screens.
The main OOAD risks are not functional blockers, but documentation and architecture-alignment issues:
1. Chrome/Web cannot run real TFLite YOLO and therefore uses fallback demo detection.
2. Flutter Clean Architecture and BLoC are partial outside the auth/app shell layer.
3. FCM backend is log-only until Firebase Admin credentials are configured.
4. A few design elements were merged or moved, especially `UserRole`, `/guardian/user-status`, and backend alert strategy.
### Required Actions Before Final Submission
| ID | Action | Owner | Due |
|---|---|---|---|
| ACT-01 | Update presentation script: real YOLO must be shown on Android, not Chrome. | Flutter Engineer | Before demo |
| ACT-02 | Update class diagram: `UserRole` is either added as enum or shown as `String role`. | OOAD Lead | Before report export |
| ACT-03 | Update sequence diagram UC-03 to include platform branch: native YOLO vs Web fallback. | OOAD Lead | Before report export |
| ACT-04 | Update API endpoint table: `/guardian/user-status` is merged into dashboard/status APIs, or add alias endpoint. | Backend Engineer | Before final freeze |
| ACT-05 | Mark FCM as log-only unless Firebase Admin credentials are configured. | Backend Engineer | Before demo |
| ACT-06 | Avoid claiming full offline-first/Drift unless local cache implementation is expanded. | Report Owner | Before submission |
| ACT-07 | Archive or replace stale audit docs that mention classes not present in current source. | OOAD Lead | Before submission |
---
This document is produced as the OOAD Phase 1C traceability audit deliverable and should be stored with the original design artifacts under `ooad-docs/design-audit/`.
+7 -31
View File
@@ -178,8 +178,7 @@
\vspace*{2cm}
{\fontsize{14}{18}\selectfont\textbf{LAPORAN FINAL EXAM}}\\[0.4cm]
{\fontsize{12}{16}\selectfont Object-Oriented Analysis and Design}\\[0.3cm]
{\fontsize{11}{14}\selectfont Flutter $\times$ Spring Boot $\times$ Artificial Intelligence}
Flutter $\times$ Spring Boot $\times$ OOAD
\vspace{1.5cm}
\begin{tikzpicture}
@@ -193,14 +192,14 @@
{\fontsize{16}{20}\selectfont AI-Powered Navigation for the Visually Impaired}
\vspace{2cm}
\begin{tcolorbox}[colback=white!15, colframe=white!40, arc=8pt, width=12cm]
\centering\color{white}
\begin{tcolorbox}[colback=white, colframe=primaryblue, arc=8pt, width=12cm]
\centering\color{black}
\begin{tabular}{ll}
\textbf{Kelompok} & 08 \\[4pt]
\textbf{Anggota 1} & Evan \\
\textbf{Anggota 2} & Japson \\
\textbf{Anggota 3} & Bambang \\[4pt]
\textbf{Mata Kuliah} & Object-Oriented Analysis and Design \\
\textbf{Anggota 1} & Evan William / 5803024001 \\
\textbf{Anggota 2} & Jap Robertus K. Setiabudi / 5803024004 \\
\textbf{Anggota 3} & Bambang Herlambang / 5803024019 \\[4pt]
\textbf{Mata Kuliah} & Object-Oriented Analysis and Design \\ & Mobile Device Programming \\ & Web Programming Framework \\
\textbf{Tanggal} & 19 Mei 2026 \\
\end{tabular}
\end{tcolorbox}
@@ -1309,29 +1308,6 @@ Each segment displays:
The existing \code{GET /guardian/location-history} endpoint (paginated, ordered by \code{created\_at DESC}) provides the raw data. The timeline grouping logic runs client-side in the Guardian Flutter app.
% ═══════════════════════════════════════════════════════════════════════════
\chapter{Team Contribution}
% ═══════════════════════════════════════════════════════════════════════════
\begin{table}[H]
\centering
\caption{Team Member Responsibilities}
\label{tab:team}
\begin{tabularx}{\textwidth}{L{2.5cm} L{4cm} L{7cm}}
\toprule
\textbf{Member} & \textbf{Primary Role} & \textbf{Contributions} \\
\midrule
Evan & Backend Engineer & Spring Boot architecture, all 14 service classes, Flyway migrations V4--V16, JWT security, WebSocket broadcaster, k6 load tests, JaCoCo coverage, OpenAPI YAML \\
\midrule
Japson & Flutter Engineer & Flutter Clean Architecture setup, GoRouter configuration, WalkGuide YOLO pipeline, TTS/STT services, voice command handler, Guardian dashboard screens, Agora call integration \\
\midrule
Bambang & OOAD Lead & All 6 OOAD diagram types (PlantUML), design pattern documentation, traceability audit, report authoring, Flutter unit/widget tests, integration test flows \\
\bottomrule
\end{tabularx}
\end{table}
All three members contributed commits to both the Flutter and Spring Boot repositories. OOAD artifacts were submitted before development commenced (Week 2--3 checkpoint). Contribution percentages are cross-referenced with GitHub commit history in the repository README.
% ═══════════════════════════════════════════════════════════════════════════
\chapter{Conclusion}
% ═══════════════════════════════════════════════════════════════════════════
File diff suppressed because it is too large Load Diff
+7
View File
@@ -104,6 +104,13 @@
<!-- yang di-copy dari Agora open-source token builder. Tidak perlu library eksternal. -->
<!-- Referensi: https://github.com/AgoraIO/Tools/tree/master/DynamicKey/AgoraDynamicKey/java -->
<!-- FIREBASE ADMIN SDK: FCM push + Firestore notification audit trail -->
<dependency>
<groupId>com.google.firebase</groupId>
<artifactId>firebase-admin</artifactId>
<version>9.3.0</version>
</dependency>
<!-- TESTING -->
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -0,0 +1,10 @@
# Copy this file to walkguide-backend/demo/secrets.properties.
# secrets.properties is gitignored and is imported by application.properties.
DB_URL=jdbc:postgresql://<host>:<port>/<database>
DB_USERNAME=<database_username>
DB_PASSWORD=<database_password>
JWT_SECRET=<base64_hs256_secret_at_least_32_bytes>
AGORA_APP_ID=<agora_app_id>
AGORA_APP_CERTIFICATE=<agora_app_certificate>
FIREBASE_CREDENTIALS_PATH=classpath:firebase/google-services-admin.json
@@ -0,0 +1,52 @@
package com.walkguide.config;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import java.io.InputStream;
@Component
@RequiredArgsConstructor
@Slf4j
public class FirebaseConfig {
private final ResourceLoader resourceLoader;
@Value("${firebase.credentials-path:classpath:firebase/google-services-admin.json}")
private String credentialsPath;
@PostConstruct
void initializeFirebase() {
if (!FirebaseApp.getApps().isEmpty()) {
log.info("[FIREBASE] FirebaseApp already initialized");
return;
}
try {
Resource resource = resourceLoader.getResource(credentialsPath);
if (!resource.exists() || !resource.isReadable()) {
log.warn("[FIREBASE] Credential not found/readable at {}. FCM runs in log-only fallback.", credentialsPath);
return;
}
try (InputStream in = resource.getInputStream()) {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(in))
.build();
FirebaseApp.initializeApp(options);
}
log.info("[FIREBASE] Firebase Admin initialized from {}", credentialsPath);
} catch (Exception e) {
log.warn("[FIREBASE] Failed to initialize Firebase Admin. FCM fallback active: {}", e.getMessage());
}
}
}
@@ -36,11 +36,14 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// Endpoint WebSocket utama
// Endpoint WebSocket utama untuk Flutter/stomp_dart_client.
// Flutter connect ke: ws://host:port/ws (tanpa SockJS)
// Browser/Postman bisa pakai SockJS fallback: http://host:port/ws
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*") // Allow semua origin untuk testing HP di LAN
.withSockJS(); // SockJS fallback untuk browser compatibility
.setAllowedOriginPatterns("*"); // Allow semua origin untuk testing HP di LAN
// Endpoint fallback SockJS untuk browser tooling bila dibutuhkan.
registry.addEndpoint("/ws-sockjs")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
@@ -4,12 +4,9 @@ import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.dto.request.CallTokenRequest;
import com.walkguide.dto.response.AgoraTokenResponse;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.AgoraTokenService;
import com.walkguide.service.FcmService;
import com.walkguide.service.CallNotificationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -17,23 +14,16 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Controller untuk VoIP call via Agora RTC.
*
* FLOW:
* 1. Caller → POST /shared/call/token → dapat Agora token + channelName
* 2. Caller join Agora channel (di Flutter)
* 3. Caller → POST /shared/call/notify → FCM "Incoming Call" dikirim ke receiver
* 4. Receiver terima FCM → join channel yang sama
* 5. Audio call tersambung via Agora
*
* Route: /api/v1/shared/call
* Auth: Authenticated (Guardian ATAU User)
*/
@RestController
@RequestMapping("/api/v1/shared/call")
@RequiredArgsConstructor
@@ -43,108 +33,86 @@ import java.util.Map;
public class CallController {
private final AgoraTokenService agoraTokenService;
private final FcmService fcmService;
private final UserRepository userRepository;
private final CallNotificationService callNotificationService;
/**
* Generate Agora RTC token untuk call session.
* Dipanggil oleh CALLER sebelum mulai call.
*
* POST /api/v1/shared/call/token
*/
@PostMapping("/token")
@Operation(summary = "Generate Agora token", description = "Caller minta token sebelum join Agora channel")
@Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
@Valid @RequestBody CallTokenRequest req) {
Long callerId = SecurityHelper.getCurrentUserId();
AgoraTokenResponse response = agoraTokenService.generateToken(callerId, req.getReceiverId());
log.info("[CALL] Token generated | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), response.getChannelName());
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
}
/**
* Kirim FCM "Incoming Call" ke receiver.
* Dipanggil oleh CALLER setelah berhasil join Agora channel.
*
* POST /api/v1/shared/call/notify
*/
@PostMapping("/notify")
@Operation(summary = "Notify receiver of incoming call",
description = "Kirim FCM push notification ke receiver agar join Agora channel yang sama")
public ResponseEntity<ApiResponse<Void>> notifyCall(
@Valid @RequestBody CallNotifyRequest req) {
@Operation(summary = "Notify receiver of incoming call")
public ResponseEntity<ApiResponse<Void>> notifyCall(@Valid @RequestBody CallNotifyRequest req) {
Long callerId = SecurityHelper.getCurrentUserId();
// Ambil info caller untuk notifikasi
User caller = userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
// Ambil FCM token receiver
User receiver = userRepository.findById(req.getReceiverId())
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
log.warn("[CALL] Receiver {} tidak punya FCM token — call notify gagal", req.getReceiverId());
return ResponseEntity.ok(ApiResponse.ok(null,
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
}
// Payload FCM untuk incoming call
Map<String, String> payload = Map.of(
"type", "INCOMING_CALL",
"callerId", String.valueOf(callerId),
"callerName", caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail(),
"channelName", req.getChannelName(),
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
"receiverUid", String.valueOf(req.getReceiverUid())
);
// High priority karena incoming call harus segera muncul
fcmService.sendHighPriority(
receiver.getFcmToken(),
"📞 Panggilan Masuk",
"Panggilan dari " + (caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail()),
payload
);
log.info("[CALL] Incoming call notification sent | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), req.getChannelName());
return ResponseEntity.ok(ApiResponse.ok(null, "Notifikasi panggilan berhasil dikirim"));
String message = callNotificationService.notifyIncomingCall(callerId, req);
return ResponseEntity.ok(ApiResponse.ok(null, message));
}
@PostMapping("/accept")
@Operation(summary = "Receiver accepts incoming call")
public ResponseEntity<ApiResponse<Map<String, String>>> acceptCall(@RequestBody Map<String, String> body) {
Long receiverId = SecurityHelper.getCurrentUserId();
Long callerId = Long.parseLong(body.get("callerId"));
String channelName = body.get("channelName");
return ResponseEntity.ok(ApiResponse.ok(
callNotificationService.acceptCall(receiverId, callerId, channelName),
"Call accepted"
));
}
@GetMapping("/pending")
@Operation(summary = "Get pending incoming call for logged-in receiver")
public ResponseEntity<ApiResponse<Map<String, String>>> pendingCall() {
Long receiverId = SecurityHelper.getCurrentUserId();
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getPendingCall(receiverId), "Pending call"));
}
@DeleteMapping("/pending")
@Operation(summary = "Clear pending incoming call for logged-in receiver")
public ResponseEntity<ApiResponse<Void>> clearPendingCall() {
Long receiverId = SecurityHelper.getCurrentUserId();
callNotificationService.clearPendingCall(receiverId);
return ResponseEntity.ok(ApiResponse.ok(null, "Pending call cleared"));
}
@GetMapping("/accepted")
@Operation(summary = "Get accepted call for logged-in caller")
public ResponseEntity<ApiResponse<Map<String, String>>> acceptedCall() {
Long callerId = SecurityHelper.getCurrentUserId();
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getAcceptedCall(callerId), "Accepted call"));
}
@DeleteMapping("/accepted")
@Operation(summary = "Clear accepted call for logged-in caller")
public ResponseEntity<ApiResponse<Void>> clearAcceptedCall() {
Long callerId = SecurityHelper.getCurrentUserId();
callNotificationService.clearAcceptedCall(callerId);
return ResponseEntity.ok(ApiResponse.ok(null, "Accepted call cleared"));
}
@GetMapping("/state")
@Operation(summary = "Get call state by Agora channel")
public ResponseEntity<ApiResponse<Map<String, String>>> callState(@RequestParam String channelName) {
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getCallState(channelName), "Call state"));
}
/**
* End call — notifikasi ke pihak lain bahwa call sudah berakhir.
* Opsional: Flutter bisa handle end call secara lokal juga.
*
* POST /api/v1/shared/call/end
*/
@PostMapping("/end")
@Operation(summary = "Notify end of call")
public ResponseEntity<ApiResponse<Void>> endCall(
@RequestBody Map<String, Long> body) {
Long callerId = SecurityHelper.getCurrentUserId();
Long otherId = body.get("otherId");
if (otherId != null) {
userRepository.findById(otherId).ifPresent(other -> {
if (other.getFcmToken() != null && !other.getFcmToken().isBlank()) {
fcmService.sendToToken(
other.getFcmToken(),
"Panggilan Berakhir",
"Panggilan telah berakhir",
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
);
}
});
public ResponseEntity<ApiResponse<Void>> endCall(@RequestBody Map<String, String> body) {
Long callerId = SecurityHelper.getCurrentUserId();
Long otherId = Long.parseLong(body.get("otherId"));
String channelName = body.get("channelName");
if (channelName == null || channelName.isBlank()) {
callNotificationService.notifyCallEnded(callerId, otherId);
} else {
callNotificationService.notifyCallEnded(callerId, otherId, channelName);
}
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
}
}
@@ -51,7 +51,7 @@ public class GuardianController {
Long guardianId = SecurityHelper.getCurrentUserId();
// Perlu ambil userId dulu — delegasikan ke service
return ResponseEntity.ok(ApiResponse.ok(
locationService.getLocationHistory(guardianId,
locationService.getLocationHistoryForGuardian(guardianId,
PageRequest.of(page, size, Sort.by("createdAt").descending())),
"Riwayat lokasi"));
}
@@ -98,6 +98,13 @@ public class GuardianController {
"SOS diakui"));
}
@PutMapping("/sos/{id}/resolve")
public ResponseEntity<ApiResponse<SosEventResponse>> resolveSos(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.ok(
sosService.resolveSos(SecurityHelper.getCurrentUserId(), id),
"SOS ditangani"));
}
@GetMapping("/ai-config")
public ResponseEntity<ApiResponse<AiConfigResponse>> getAiConfig() {
// Guardian lihat config user yang dipair
@@ -117,7 +124,7 @@ public class GuardianController {
@GetMapping("/voice-commands")
public ResponseEntity<ApiResponse<?>> getVoiceCommands() {
return ResponseEntity.ok(ApiResponse.ok(
voiceCommandService.getAll(SecurityHelper.getCurrentUserId()),
voiceCommandService.getAllForGuardian(SecurityHelper.getCurrentUserId()),
"Voice commands"));
}
@@ -132,10 +139,18 @@ public class GuardianController {
@GetMapping("/shortcuts")
public ResponseEntity<ApiResponse<?>> getShortcuts() {
return ResponseEntity.ok(ApiResponse.ok(
hardwareShortcutService.getAll(SecurityHelper.getCurrentUserId()),
hardwareShortcutService.getAllForGuardian(SecurityHelper.getCurrentUserId()),
"Hardware shortcuts"));
}
@PutMapping("/shortcuts")
public ResponseEntity<ApiResponse<HardwareShortcutResponse>> updateShortcut(
@RequestBody HardwareShortcutUpdateRequest req) {
return ResponseEntity.ok(ApiResponse.ok(
hardwareShortcutService.updateByGuardian(SecurityHelper.getCurrentUserId(), req),
"Hardware shortcut diperbarui"));
}
@GetMapping("/geofence")
public ResponseEntity<ApiResponse<GeofenceResponse>> getGeofence() {
return ResponseEntity.ok(ApiResponse.ok(
@@ -2,9 +2,11 @@ package com.walkguide.controller;
import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.request.*;
import com.walkguide.dto.response.PairingCodeResponse;
import com.walkguide.dto.response.PairingStatusResponse;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.PairingService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -16,12 +18,26 @@ public class PairingController {
private final PairingService pairingService;
@GetMapping("/code")
public ResponseEntity<ApiResponse<PairingCodeResponse>> code() {
return ResponseEntity.ok(ApiResponse.ok(
pairingService.getOrCreatePairingCode(SecurityHelper.getCurrentUserId()),
"Pairing code aktif"));
}
@PostMapping("/code/regenerate")
public ResponseEntity<ApiResponse<PairingCodeResponse>> regenerateCode() {
return ResponseEntity.ok(ApiResponse.ok(
pairingService.regeneratePairingCode(SecurityHelper.getCurrentUserId()),
"Pairing code baru dibuat"));
}
@PostMapping("/invite")
public ResponseEntity<ApiResponse<PairingStatusResponse>> invite(
@RequestBody InviteUserRequest req) {
@Valid @RequestBody InviteUserRequest req) {
Long guardianId = SecurityHelper.getCurrentUserId();
return ResponseEntity.ok(ApiResponse.ok(
pairingService.inviteUser(guardianId, req.getUniqueUserId()),
pairingService.inviteUser(guardianId, req.resolveSubmittedCode()),
"Undangan dikirim ke user"));
}
@@ -3,8 +3,6 @@ package com.walkguide.controller;
import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.request.*;
import com.walkguide.dto.response.*;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.*;
import lombok.RequiredArgsConstructor;
@@ -29,21 +27,13 @@ public class UserController {
private final AiConfigService aiConfigService;
private final VoiceCommandService voiceCommandService;
private final HardwareShortcutService hardwareShortcutService;
private final UserRepository userRepository;
private final UserService userService;
@GetMapping("/profile")
public ResponseEntity<ApiResponse<?>> getProfile() {
Long userId = SecurityHelper.getCurrentUserId();
var user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User tidak ditemukan"));
var profile = java.util.Map.of(
"id", user.getId(),
"email", user.getEmail(),
"displayName", user.getDisplayName() != null ? user.getDisplayName() : "",
"role", user.getRole(),
"uniqueUserId", user.getUniqueUserId() != null ? user.getUniqueUserId() : ""
);
return ResponseEntity.ok(ApiResponse.ok(profile, "Profil user"));
return ResponseEntity.ok(ApiResponse.ok(
userService.getProfile(SecurityHelper.getCurrentUserId()),
"Profil user"));
}
@GetMapping("/settings")
@@ -163,19 +153,13 @@ public class UserController {
@PostMapping("/walkguide/start")
public ResponseEntity<ApiResponse<Void>> walkGuideStart() {
Long userId = SecurityHelper.getCurrentUserId();
userRepository.findById(userId).ifPresent(u ->
activityLogService.createLog(u, ActivityLogType.WALKGUIDE_START,
"WalkGuide dimulai", null));
userService.logWalkGuideStart(SecurityHelper.getCurrentUserId());
return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dimulai"));
}
@PostMapping("/walkguide/stop")
public ResponseEntity<ApiResponse<Void>> walkGuideStop() {
Long userId = SecurityHelper.getCurrentUserId();
userRepository.findById(userId).ifPresent(u ->
activityLogService.createLog(u, ActivityLogType.WALKGUIDE_STOP,
"WalkGuide dihentikan", null));
userService.logWalkGuideStop(SecurityHelper.getCurrentUserId());
return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dihentikan"));
}
}
@@ -24,6 +24,8 @@ public class CallNotifyRequest {
/** Token Agora untuk receiver — dikirim lewat FCM payload */
private String agoraToken;
private String agoraAppId;
/** UID Agora untuk receiver */
private int receiverUid;
}
@@ -5,7 +5,16 @@ import lombok.Data;
@Data
public class InviteUserRequest {
@NotBlank(message = "User ID tidak boleh kosong")
@Size(min = 12, max = 12, message = "User ID harus tepat 12 karakter")
private String uniqueUserId;
@Size(min = 8, max = 8, message = "Pairing code harus tepat 8 karakter")
private String pairingCode;
public String resolveSubmittedCode() {
if (pairingCode != null && !pairingCode.isBlank()) {
return pairingCode.trim().toUpperCase();
}
return uniqueUserId == null ? null : uniqueUserId.trim().toUpperCase();
}
}
@@ -8,4 +8,5 @@ public class LocationUpdateRequest {
private Double accuracy;
private Double speed;
private Double heading;
private Integer batteryLevel;
}
@@ -4,6 +4,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@NoArgsConstructor
@@ -22,6 +23,8 @@ public class DashboardResponse {
// Status
private long unreadSosCount;
private long unreadNotifCount;
private long obstaclesToday;
private Map<String, Object> userStatus;
// Recent activity (5 terbaru)
private List<ActivityLogResponse> recentActivity;
@@ -16,5 +16,6 @@ public class LocationResponse {
private Double accuracy;
private Double speed;
private Double heading;
private Integer batteryLevel;
private LocalDateTime createdAt;
}
@@ -0,0 +1,14 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class PairingCodeResponse {
private String pairingCode;
private LocalDateTime expiresAt;
private long expiresInSeconds;
}
@@ -12,6 +12,8 @@ public class PairingStatusResponse {
private String pairedWithName; // nama Guardian (untuk User) atau nama User (untuk Guardian)
private String pairedWithEmail;
private String uniqueUserId; // ID user yang di-pair
private String pairingCode; // temporary code for new pairing flow
private LocalDateTime pairingCodeExpiresAt;
private LocalDateTime invitedAt;
private LocalDateTime respondedAt;
}
@@ -29,6 +29,9 @@ public class LocationHistory {
private Double speed; // m/s
private Double heading; // derajat 0-360
@Column(name = "battery_level")
private Integer batteryLevel;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -29,6 +29,12 @@ public class User {
@Column(name = "unique_user_id", unique = true, length = 12)
private String uniqueUserId;
@Column(name = "pairing_code", unique = true, length = 8)
private String pairingCode;
@Column(name = "pairing_code_expires_at")
private LocalDateTime pairingCodeExpiresAt;
@Column(name = "display_name", length = 100)
private String displayName;
@@ -1,6 +1,7 @@
package com.walkguide.exception;
import com.walkguide.dto.ApiResponse;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -29,10 +30,22 @@ public class GlobalExceptionHandler {
.body(ApiResponse.error("VALIDATION_ERROR", msg));
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiResponse<Object>> handleDataIntegrity(DataIntegrityViolationException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResponse.error("DATA_CONFLICT",
"Data pairing lama masih bentrok. Refresh status atau unpair dulu, lalu coba lagi."));
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse<Object>> handleRuntime(RuntimeException ex) {
String message = ex.getMessage();
if ("Email tidak terdaftar".equals(message) || "Password salah".equals(message)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error("AUTH_INVALID", message));
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("INTERNAL_ERROR", ex.getMessage()));
.body(ApiResponse.error("INTERNAL_ERROR", message));
}
@ExceptionHandler(Exception.class)
@@ -4,7 +4,11 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository
public interface ObstacleLogRepository extends JpaRepository<ObstacleLog, Long> {
Page<ObstacleLog> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
long countByUserIdAndCreatedAtAfter(Long userId, LocalDateTime createdAt);
}
@@ -9,5 +9,6 @@ import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByUniqueUserId(String uniqueUserId);
Optional<User> findByPairingCode(String pairingCode);
boolean existsByEmail(String email);
}
@@ -8,7 +8,10 @@ import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@@ -82,7 +85,34 @@ public class JwtUtil {
}
private Key getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
byte[] keyBytes = decodeSecret(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
private byte[] decodeSecret(String configuredSecret) {
String trimmed = configuredSecret == null ? "" : configuredSecret.trim();
if (trimmed.isEmpty()) {
throw new IllegalStateException("JWT secret must not be empty");
}
byte[] keyBytes;
try {
keyBytes = Decoders.BASE64.decode(trimmed);
} catch (RuntimeException base64Error) {
try {
keyBytes = Decoders.BASE64URL.decode(trimmed);
} catch (RuntimeException base64UrlError) {
keyBytes = trimmed.getBytes(StandardCharsets.UTF_8);
}
}
if (keyBytes.length >= 32) {
return keyBytes;
}
try {
return MessageDigest.getInstance("SHA-256").digest(keyBytes);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is not available", e);
}
}
}
@@ -0,0 +1,175 @@
package com.walkguide.service;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import com.walkguide.websocket.LocationBroadcaster;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@Slf4j
public class CallNotificationService {
private final FcmService fcmService;
private final UserRepository userRepository;
private final LocationBroadcaster locationBroadcaster;
private final Map<Long, Map<String, String>> pendingCalls = new ConcurrentHashMap<>();
private final Map<Long, Map<String, String>> acceptedCalls = new ConcurrentHashMap<>();
private final Map<String, Map<String, String>> callStates = new ConcurrentHashMap<>();
public String notifyIncomingCall(Long callerId, CallNotifyRequest req) {
User caller = userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
User receiver = userRepository.findById(req.getReceiverId())
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
Map<String, String> payload = new HashMap<>();
payload.put("type", "INCOMING_CALL");
payload.put("status", "RINGING");
payload.put("callerId", String.valueOf(callerId));
payload.put("receiverId", String.valueOf(receiver.getId()));
payload.put("callerName", callerName);
payload.put("channelName", req.getChannelName());
payload.put("agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "");
payload.put("agoraAppId", req.getAgoraAppId() != null ? req.getAgoraAppId() : "");
payload.put("receiverUid", String.valueOf(req.getReceiverUid()));
pendingCalls.put(receiver.getId(), payload);
acceptedCalls.remove(callerId);
callStates.put(req.getChannelName(), payload);
locationBroadcaster.broadcastCall(receiver.getId(), payload);
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId());
return "Panggilan dikirim via realtime fallback.";
}
fcmService.sendHighPriority(
receiver.getFcmToken(),
"Panggilan Masuk",
"Panggilan dari " + callerName,
payload
);
log.info("[CALL] Incoming call notification sent | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), req.getChannelName());
return "Notifikasi panggilan berhasil dikirim";
}
public Map<String, String> acceptCall(Long receiverId, Long callerId, String channelName) {
User receiver = userRepository.findById(receiverId)
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
pendingCalls.remove(receiverId);
String receiverName = receiver.getDisplayName() != null ? receiver.getDisplayName() : receiver.getEmail();
Map<String, String> payload = new HashMap<>(getCallState(channelName));
payload.put("type", "CALL_ACCEPTED");
payload.put("status", "ACCEPTED");
payload.put("callerId", String.valueOf(callerId));
payload.put("receiverId", String.valueOf(receiverId));
payload.put("receiverName", receiverName);
payload.put("channelName", channelName != null ? channelName : "");
payload.put("acceptedBy", String.valueOf(receiverId));
payload.put("acceptedAt", String.valueOf(System.currentTimeMillis()));
acceptedCalls.put(callerId, payload);
if (channelName != null && !channelName.isBlank()) {
callStates.put(channelName, payload);
}
locationBroadcaster.broadcastCall(callerId, payload);
locationBroadcaster.broadcastCall(receiverId, payload);
log.info("[CALL] Call accepted | caller={} receiver={} channel={}", callerId, receiverId, channelName);
return payload;
}
public Map<String, String> getPendingCall(Long receiverId) {
return pendingCalls.get(receiverId);
}
public void clearPendingCall(Long receiverId) {
pendingCalls.remove(receiverId);
}
public Map<String, String> getAcceptedCall(Long callerId) {
return acceptedCalls.get(callerId);
}
public void clearAcceptedCall(Long callerId) {
acceptedCalls.remove(callerId);
}
public Map<String, String> getCallState(String channelName) {
if (channelName == null || channelName.isBlank()) {
return new HashMap<>();
}
return callStates.getOrDefault(channelName, new HashMap<>());
}
public void notifyCallEnded(Long callerId, Long otherId) {
notifyCallEnded(callerId, otherId, null);
}
public void notifyCallEnded(Long callerId, Long otherId, String channelName) {
if (otherId == null) {
return;
}
clearPendingCall(otherId);
clearPendingCall(callerId);
clearAcceptedCall(callerId);
clearAcceptedCall(otherId);
String resolvedChannel = channelName;
if (resolvedChannel == null || resolvedChannel.isBlank()) {
resolvedChannel = findActiveChannel(callerId, otherId);
}
Map<String, String> payload = new HashMap<>(getCallState(resolvedChannel));
payload.put("type", "CALL_ENDED");
payload.put("status", "ENDED");
payload.put("callerId", String.valueOf(callerId));
payload.put("otherId", String.valueOf(otherId));
payload.put("channelName", resolvedChannel != null ? resolvedChannel : "");
payload.put("endedBy", String.valueOf(callerId));
payload.put("endedAt", String.valueOf(System.currentTimeMillis()));
if (resolvedChannel != null && !resolvedChannel.isBlank()) {
callStates.put(resolvedChannel, payload);
}
locationBroadcaster.broadcastCall(otherId, payload);
locationBroadcaster.broadcastCall(callerId, payload);
userRepository.findById(otherId).ifPresent(other -> {
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
return;
}
fcmService.sendToToken(
other.getFcmToken(),
"Panggilan Berakhir",
"Panggilan telah berakhir",
payload
);
});
}
private String findActiveChannel(Long userA, Long userB) {
String a = String.valueOf(userA);
String b = String.valueOf(userB);
return callStates.entrySet().stream()
.filter(e -> a.equals(e.getValue().get("callerId")) && b.equals(e.getValue().get("receiverId"))
|| b.equals(e.getValue().get("callerId")) && a.equals(e.getValue().get("receiverId")))
.map(Map.Entry::getKey)
.findFirst()
.orElse(null);
}
}
@@ -1,50 +1,130 @@
package com.walkguide.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import com.google.cloud.Timestamp;
import com.google.cloud.firestore.Firestore;
import com.google.firebase.FirebaseApp;
import com.google.firebase.cloud.FirestoreClient;
import com.google.firebase.messaging.AndroidConfig;
import com.google.firebase.messaging.AndroidNotification;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
/**
* FCM Service untuk push notification.
* Saat ini dalam mode LOG-ONLY agar tidak butuh Firebase credentials dulu.
* Untuk enable FCM nyata: uncomment bagian Firebase dan tambah dependency Firebase Admin SDK.
* FCM Service untuk push notification dan audit notifikasi ke Firestore.
* Jika Firebase credential belum tersedia, service tetap aman berjalan dalam mode log-only.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class FcmService {
public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) {
if (fcmToken == null || fcmToken.isBlank()) {
log.warn("[FCM] Token kosong, skip notifikasi: {} - {}", title, body);
return;
}
// LOG ONLY untuk sekarang
log.info("[FCM] TO={} | TITLE={} | BODY={} | DATA={}", fcmToken, title, body, data);
private static final Logger log = LoggerFactory.getLogger(FcmService.class);
// TODO: uncomment ini setelah tambah Firebase Admin SDK ke pom.xml
// dan taruh google-services-admin.json di src/main/resources/firebase/
//
// try {
// Message message = Message.builder()
// .setToken(fcmToken)
// .setNotification(Notification.builder().setTitle(title).setBody(body).build())
// .putAllData(data != null ? data : Map.of())
// .setAndroidConfig(AndroidConfig.builder()
// .setPriority(AndroidConfig.Priority.HIGH)
// .build())
// .build();
// String response = FirebaseMessaging.getInstance().send(message);
// log.info("[FCM] Sent successfully: {}", response);
// } catch (FirebaseMessagingException e) {
// log.error("[FCM] Failed to send: {}", e.getMessage());
// }
public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) {
sendInternal(fcmToken, title, body, data, false);
}
public void sendHighPriority(String fcmToken, String title, String body, Map<String, String> data) {
// SOS dan incoming call pakai ini - sama untuk sekarang
sendToToken(fcmToken, title, body, data);
sendInternal(fcmToken, title, body, data, true);
}
@Value("${firebase.notifications-collection:notifications}")
private String notificationsCollection;
private void sendInternal(String fcmToken, String title, String body, Map<String, String> data, boolean highPriority) {
Map<String, String> safeData = data != null ? data : Map.of();
String status = "SKIPPED";
String messageId = null;
if (fcmToken == null || fcmToken.isBlank()) {
log.warn("[FCM] Token kosong, skip notifikasi: {} - {}", title, body);
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, null);
return;
}
if (FirebaseApp.getApps().isEmpty()) {
status = "LOG_ONLY";
log.info("[FCM] LOG_ONLY TO={} | TITLE={} | BODY={} | DATA={}",
maskToken(fcmToken), title, body, safeData);
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, null);
return;
}
try {
AndroidConfig.Priority priority = highPriority
? AndroidConfig.Priority.HIGH
: AndroidConfig.Priority.NORMAL;
AndroidNotification androidNotification = AndroidNotification.builder()
.setChannelId(highPriority ? "walkguide_urgent" : "walkguide_alerts")
.setPriority(highPriority
? AndroidNotification.Priority.MAX
: AndroidNotification.Priority.DEFAULT)
.build();
Message message = Message.builder()
.setToken(fcmToken)
.setNotification(Notification.builder()
.setTitle(title != null ? title : "WalkGuide")
.setBody(body != null ? body : "")
.build())
.putAllData(safeData)
.setAndroidConfig(AndroidConfig.builder()
.setPriority(priority)
.setNotification(androidNotification)
.build())
.build();
messageId = FirebaseMessaging.getInstance().send(message);
status = "SENT";
log.info("[FCM] Sent {} notification successfully: {}", highPriority ? "high-priority" : "normal", messageId);
} catch (Exception e) {
status = "FAILED";
log.error("[FCM] Failed to send notification: {}", e.getMessage());
} finally {
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, messageId);
}
}
private void saveNotificationAudit(String fcmToken, String title, String body, Map<String, String> data,
boolean highPriority, String status, String messageId) {
if (FirebaseApp.getApps().isEmpty()) {
return;
}
try {
Firestore firestore = FirestoreClient.getFirestore();
Map<String, Object> doc = new HashMap<>();
doc.put("title", title);
doc.put("body", body);
doc.put("type", data.getOrDefault("type", "GENERAL"));
doc.put("data", data);
doc.put("priority", highPriority ? "HIGH" : "NORMAL");
doc.put("status", status);
doc.put("messageId", messageId);
doc.put("recipientTokenMasked", maskToken(fcmToken));
doc.put("createdAt", Timestamp.ofTimeSecondsAndNanos(Instant.now().getEpochSecond(), 0));
firestore.collection(notificationsCollection).add(doc).get();
log.debug("[FIRESTORE] Notification audit saved | type={} status={}", doc.get("type"), status);
} catch (Exception e) {
log.warn("[FIRESTORE] Notification audit skipped: {}", e.getMessage());
}
}
private String maskToken(String token) {
if (token == null || token.isBlank()) {
return "";
}
int visible = Math.min(6, token.length());
return "***" + token.substring(token.length() - visible);
}
}
@@ -8,6 +8,11 @@ import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class GuardianDashboardService {
@@ -17,6 +22,7 @@ public class GuardianDashboardService {
private final ActivityLogService activityLogService;
private final SosEventRepository sosEventRepository;
private final GuardianNotificationRepository notifRepository;
private final ObstacleLogRepository obstacleLogRepository;
public DashboardResponse getDashboard(Long guardianId) {
var pairing = pairingRelationRepository
@@ -40,6 +46,21 @@ public class GuardianDashboardService {
long unreadSos = sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, 100))
.stream().filter(s -> s.getStatus().name().equals("TRIGGERED")).count();
long unreadNotif = notifRepository.countByUserIdAndIsReadFalse(userId);
long obstaclesToday = obstacleLogRepository.countByUserIdAndCreatedAtAfter(
userId,
LocalDateTime.of(LocalDateTime.now().toLocalDate(), LocalTime.MIN)
);
Map<String, Object> userStatus = new HashMap<>();
userStatus.put("displayName", user.getDisplayName());
userStatus.put("email", user.getEmail());
userStatus.put("online", lastLocation != null
&& lastLocation.getCreatedAt() != null
&& lastLocation.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(2)));
userStatus.put("lastSeenAt", lastLocation != null ? lastLocation.getCreatedAt() : null);
userStatus.put("battery", lastLocation != null ? lastLocation.getBatteryLevel() : null);
userStatus.put("lastSpeed", lastLocation != null ? lastLocation.getSpeed() : null);
userStatus.put("obstaclesToday", obstaclesToday);
return DashboardResponse.builder()
.pairedUserId(userId)
@@ -49,6 +70,8 @@ public class GuardianDashboardService {
.lastLocation(lastLocation)
.unreadSosCount(unreadSos)
.unreadNotifCount(unreadNotif)
.obstaclesToday(obstaclesToday)
.userStatus(userStatus)
.recentActivity(recentActivity)
.build();
}
@@ -2,9 +2,12 @@ package com.walkguide.service;
import com.walkguide.dto.request.HardwareShortcutUpdateRequest;
import com.walkguide.dto.response.HardwareShortcutResponse;
import com.walkguide.enums.PairingStatus;
import com.walkguide.enums.HardwareShortcutKey;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.HardwareShortcutRepository;
import com.walkguide.repository.PairingRelationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -15,6 +18,7 @@ import java.util.stream.Collectors;
public class HardwareShortcutService {
private final HardwareShortcutRepository hardwareShortcutRepository;
private final PairingRelationRepository pairingRelationRepository;
public List<HardwareShortcutResponse> getAll(Long userId) {
return hardwareShortcutRepository.findByUserId(userId).stream()
@@ -25,6 +29,13 @@ public class HardwareShortcutService {
.collect(Collectors.toList());
}
public List<HardwareShortcutResponse> getAllForGuardian(Long guardianId) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
return getAll(pairing.getUser().getId());
}
// Bisa dipanggil dari HP User langsung (capture button) atau dari Guardian
public HardwareShortcutResponse update(Long userId, HardwareShortcutUpdateRequest req) {
HardwareShortcutKey key = HardwareShortcutKey.valueOf(req.getShortcutKey());
@@ -43,4 +54,11 @@ public class HardwareShortcutService {
.buttonName(shortcut.getButtonName()).buttonCode(shortcut.getButtonCode())
.enabled(shortcut.getEnabled()).build();
}
public HardwareShortcutResponse updateByGuardian(Long guardianId, HardwareShortcutUpdateRequest req) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
return update(pairing.getUser().getId(), req);
}
}
@@ -6,6 +6,7 @@ import com.walkguide.entity.LocationHistory;
import com.walkguide.entity.User;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster;
@@ -41,6 +42,7 @@ public class LocationService {
.accuracy(req.getAccuracy())
.speed(req.getSpeed())
.heading(req.getHeading())
.batteryLevel(req.getBatteryLevel())
.build();
loc = locationHistoryRepository.save(loc);
@@ -79,6 +81,15 @@ public class LocationService {
.map(this::toResponse);
}
public Page<LocationResponse> getLocationHistoryForGuardian(Long guardianId, Pageable pageable) {
Long pairedUserId = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.map(pairing -> pairing.getUser().getId())
.orElseThrow(() -> new PairingException("Guardian belum terhubung dengan User aktif"));
return getLocationHistory(pairedUserId, pageable);
}
// ========== GEOFENCE ==========
private void checkGeofence(Long userId, double lat, double lng) {
@@ -126,6 +137,7 @@ public class LocationService {
return LocationResponse.builder()
.id(l.getId()).lat(l.getLat()).lng(l.getLng())
.accuracy(l.getAccuracy()).speed(l.getSpeed()).heading(l.getHeading())
.batteryLevel(l.getBatteryLevel())
.createdAt(l.getCreatedAt()).build();
}
}
@@ -1,21 +1,22 @@
package com.walkguide.service;
import com.walkguide.dto.response.PairingStatusResponse;
import com.walkguide.dto.response.PairingCodeResponse;
import com.walkguide.entity.*;
import com.walkguide.enums.*;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class PairingService {
private final PairingRelationRepository pairingRelationRepository;
@@ -26,9 +27,62 @@ public class PairingService {
private final ActivityLogService activityLogService;
private final FcmService fcmService;
private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private static final int PAIRING_CODE_LENGTH = 8;
private static final int PAIRING_CODE_TTL_MINUTES = 15;
private static final SecureRandom RANDOM = new SecureRandom();
public PairingService(PairingRelationRepository pairingRelationRepository,
UserRepository userRepository,
VoiceCommandConfigRepository voiceCommandConfigRepository,
HardwareShortcutRepository hardwareShortcutRepository,
AiConfigRepository aiConfigRepository,
ActivityLogService activityLogService,
FcmService fcmService) {
this.pairingRelationRepository = pairingRelationRepository;
this.userRepository = userRepository;
this.voiceCommandConfigRepository = voiceCommandConfigRepository;
this.hardwareShortcutRepository = hardwareShortcutRepository;
this.aiConfigRepository = aiConfigRepository;
this.activityLogService = activityLogService;
this.fcmService = fcmService;
}
@Transactional
public PairingStatusResponse inviteUser(Long guardianId, String uniqueUserId) {
// Guardian tidak boleh punya pairing ACTIVE atau PENDING
public PairingCodeResponse getOrCreatePairingCode(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("Hanya akun User yang bisa membuat pairing code.");
}
LocalDateTime now = LocalDateTime.now();
if (user.getPairingCode() == null
|| user.getPairingCodeExpiresAt() == null
|| !user.getPairingCodeExpiresAt().isAfter(now)) {
assignNewPairingCode(user, now);
userRepository.save(user);
}
return buildPairingCodeResponse(user, now);
}
@Transactional
public PairingCodeResponse regeneratePairingCode(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("Hanya akun User yang bisa membuat pairing code.");
}
LocalDateTime now = LocalDateTime.now();
assignNewPairingCode(user, now);
userRepository.save(user);
activityLogService.createLog(user, ActivityLogType.PAIRING_INVITE_SENT,
"User membuat pairing code baru", null);
return buildPairingCodeResponse(user, now);
}
@Transactional
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
}
@@ -38,8 +92,7 @@ public class PairingService {
User guardian = userRepository.findById(guardianId)
.orElseThrow(() -> new ResourceNotFoundException("Guardian tidak ditemukan"));
User user = userRepository.findByUniqueUserId(uniqueUserId)
.orElseThrow(() -> new ResourceNotFoundException("User dengan ID '" + uniqueUserId + "' tidak ditemukan"));
User user = resolveUserByPairingCode(submittedCode);
if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("ID tersebut bukan milik User. Pastikan kamu memasukkan ID yang benar.");
@@ -48,6 +101,52 @@ public class PairingService {
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
}
var existingGuardianPairing = pairingRelationRepository.findByGuardian_Id(guardianId);
if (existingGuardianPairing.isPresent()) {
PairingRelation existing = existingGuardianPairing.get();
if (existing.getStatus() == PairingStatus.ACTIVE) {
if (existing.getUser().getId().equals(user.getId())) {
return buildStatus(existing, guardian, existing.getUser(), "GUARDIAN");
}
throw new PairingException(
"Guardian sudah pairing aktif dengan User lain. Unpair dulu sebelum invite User baru.");
}
if (existing.getStatus() == PairingStatus.PENDING) {
if (existing.getUser().getId().equals(user.getId())) {
sendPairingInviteNotification(existing, guardian, user);
return buildStatus(existing, guardian, user, "GUARDIAN");
}
throw new PairingException(
"Guardian masih punya undangan pairing yang menunggu respons User.");
}
}
var existingUserPairing = pairingRelationRepository.findByUser_Id(user.getId());
if (existingUserPairing.isPresent()) {
PairingRelation existing = existingUserPairing.get();
if (existing.getStatus() == PairingStatus.ACTIVE) {
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
}
if (existing.getStatus() == PairingStatus.PENDING) {
if (existing.getGuardian().getId().equals(guardianId)) {
sendPairingInviteNotification(existing, guardian, user);
return buildStatus(existing, guardian, user, "GUARDIAN");
}
throw new PairingException("User ini masih punya undangan pairing dari Guardian lain.");
}
}
if (existingGuardianPairing.isPresent()) {
pairingRelationRepository.delete(existingGuardianPairing.get());
pairingRelationRepository.flush();
}
if (existingUserPairing.isPresent()
&& (existingGuardianPairing.isEmpty()
|| !existingUserPairing.get().getId().equals(existingGuardianPairing.get().getId()))) {
pairingRelationRepository.delete(existingUserPairing.get());
pairingRelationRepository.flush();
}
PairingRelation pairing = PairingRelation.builder()
.guardian(guardian)
.user(user)
@@ -55,11 +154,11 @@ public class PairingService {
.build();
pairing = pairingRelationRepository.save(pairing);
// Kirim FCM ke user
fcmService.sendToToken(user.getFcmToken(),
"Pairing Request",
"Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung",
Map.of("type", "PAIRING_INVITE", "guardianName", guardian.getDisplayName()));
user.setPairingCode(null);
user.setPairingCodeExpiresAt(null);
userRepository.save(user);
sendPairingInviteNotification(pairing, guardian, user);
activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT,
"Guardian mengirim invite ke " + user.getDisplayName(), null);
@@ -151,6 +250,13 @@ public class PairingService {
// ========== PRIVATE ==========
private void seedDefaults(Long guardianId, Long userId) {
voiceCommandConfigRepository.deleteByUserId(userId);
hardwareShortcutRepository.deleteByUserId(userId);
aiConfigRepository.findByUserId(userId).ifPresent(aiConfigRepository::delete);
voiceCommandConfigRepository.flush();
hardwareShortcutRepository.flush();
aiConfigRepository.flush();
// Voice commands default
List<VoiceCommandConfig> defaults = List.of(
vc(guardianId, userId, VoiceCommandKey.OPEN_WALKGUIDE, "Open Walkguide"),
@@ -199,6 +305,60 @@ public class PairingService {
.enabled(name != null).build();
}
private User resolveUserByPairingCode(String submittedCode) {
if (submittedCode == null || submittedCode.isBlank()) {
throw new PairingException("Pairing code tidak boleh kosong.");
}
String code = submittedCode.trim().toUpperCase();
User user = userRepository.findByPairingCode(code)
.orElseThrow(() -> new ResourceNotFoundException(
"Pairing code '" + code + "' tidak ditemukan. Minta User generate kode baru."));
if (user.getPairingCodeExpiresAt() == null
|| !user.getPairingCodeExpiresAt().isAfter(LocalDateTime.now())) {
user.setPairingCode(null);
user.setPairingCodeExpiresAt(null);
userRepository.save(user);
throw new PairingException("Pairing code sudah kadaluarsa. Minta User generate kode baru.");
}
return user;
}
private void sendPairingInviteNotification(PairingRelation pairing, User guardian, User user) {
fcmService.sendToToken(user.getFcmToken(),
"Pairing Request",
"Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung",
Map.of(
"type", "PAIRING_INVITE",
"pairingId", pairing.getId().toString(),
"guardianName", guardian.getDisplayName()));
}
private void assignNewPairingCode(User user, LocalDateTime now) {
String candidate;
do {
candidate = randomCode();
} while (userRepository.findByPairingCode(candidate).isPresent());
user.setPairingCode(candidate);
user.setPairingCodeExpiresAt(now.plusMinutes(PAIRING_CODE_TTL_MINUTES));
}
private String randomCode() {
StringBuilder sb = new StringBuilder(PAIRING_CODE_LENGTH);
for (int i = 0; i < PAIRING_CODE_LENGTH; i++) {
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
}
return sb.toString();
}
private PairingCodeResponse buildPairingCodeResponse(User user, LocalDateTime now) {
long seconds = Math.max(0,
ChronoUnit.SECONDS.between(now, user.getPairingCodeExpiresAt()));
return PairingCodeResponse.builder()
.pairingCode(user.getPairingCode())
.expiresAt(user.getPairingCodeExpiresAt())
.expiresInSeconds(seconds)
.build();
}
private PairingStatusResponse buildStatus(PairingRelation p, User guardian, User user, String viewerRole) {
String pairedWithName = "GUARDIAN".equals(viewerRole)
? user.getDisplayName() : guardian.getDisplayName();
@@ -211,8 +371,11 @@ public class PairingService {
.pairedWithName(pairedWithName)
.pairedWithEmail(pairedWithEmail)
.uniqueUserId(user.getUniqueUserId())
.pairingCode(user.getPairingCode())
.pairingCodeExpiresAt(user.getPairingCodeExpiresAt())
.invitedAt(p.getInvitedAt())
.respondedAt(p.getRespondedAt())
.build();
}
}
@@ -7,6 +7,7 @@ import com.walkguide.entity.User;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.enums.PairingStatus;
import com.walkguide.enums.SosStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster;
@@ -36,6 +37,14 @@ public class SosService {
@Transactional
public SosEventResponse triggerSos(Long userId, SosRequest req) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
var activePairing = pairingRelationRepository
.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
.orElseThrow(() -> new PairingException(
"SOS hanya bisa dikirim setelah User terhubung dengan Guardian aktif."));
SosEvent sos = SosEvent.builder()
.userId(userId)
.triggerType(req.getTriggerType() != null ? req.getTriggerType() : "MANUAL")
@@ -46,18 +55,13 @@ public class SosService {
sos = sosEventRepository.save(sos);
final SosEvent savedSos = sos;
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
activityLogService.createLog(user, ActivityLogType.SOS_TRIGGERED,
"SOS dikirim via " + sos.getTriggerType(), null);
SosEventResponse sosResponse = toResponse(savedSos);
// Kirim ke Guardian via FCM (background) + WebSocket (foreground)
pairingRelationRepository.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
.ifPresent(pairing -> {
User guardian = pairing.getGuardian();
User guardian = activePairing.getGuardian();
String guardianFcm = guardian.getFcmToken();
String locStr = req.getLat() != null
? String.format("Lat:%.4f,Lng:%.4f", req.getLat(), req.getLng())
@@ -78,7 +82,6 @@ public class SosService {
log.info("[SOS] Alert sent to Guardian={} for User={} | trigger={}",
guardian.getId(), userId, savedSos.getTriggerType());
});
return sosResponse;
}
@@ -87,6 +90,7 @@ public class SosService {
public SosEventResponse acknowledgeSos(Long guardianId, Long sosId) {
SosEvent sos = sosEventRepository.findById(sosId)
.orElseThrow(() -> new ResourceNotFoundException("SOS event tidak ditemukan"));
assertGuardianOwnsSos(guardianId, sos.getUserId());
sos.setStatus(SosStatus.ACKNOWLEDGED);
sos.setAcknowledgedAt(LocalDateTime.now());
@@ -111,6 +115,33 @@ public class SosService {
return toResponse(sos);
}
@Transactional
public SosEventResponse resolveSos(Long guardianId, Long sosId) {
SosEvent sos = sosEventRepository.findById(sosId)
.orElseThrow(() -> new ResourceNotFoundException("SOS event tidak ditemukan"));
assertGuardianOwnsSos(guardianId, sos.getUserId());
if (sos.getAcknowledgedAt() == null) {
sos.setAcknowledgedAt(LocalDateTime.now());
}
sos.setStatus(SosStatus.RESOLVED);
sosEventRepository.save(sos);
User user = userRepository.findById(sos.getUserId())
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
activityLogService.createLog(user, ActivityLogType.SOS_ACKNOWLEDGED,
"Guardian menandai SOS sudah ditangani", null);
pairingRelationRepository.findByUser_IdAndStatus(sos.getUserId(), PairingStatus.ACTIVE)
.ifPresent(pairing -> fcmService.sendToToken(
pairing.getUser().getFcmToken(),
"SOS Sudah Ditangani",
"Guardian kamu sudah menandai SOS sebagai selesai.",
Map.of("type", "SOS_RESOLVED")));
return toResponse(sos);
}
public Page<SosEventResponse> getSosEvents(Long userId, Pageable pageable) {
return sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable)
.map(this::toResponse);
@@ -125,6 +156,15 @@ public class SosService {
.map(this::toResponse);
}
private void assertGuardianOwnsSos(Long guardianId, Long userId) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new ResourceNotFoundException("Tidak ada user yang dipair"));
if (!pairing.getUser().getId().equals(userId)) {
throw new ResourceNotFoundException("SOS event tidak ditemukan untuk guardian ini");
}
}
private SosEventResponse toResponse(SosEvent s) {
return SosEventResponse.builder()
.id(s.getId())
@@ -0,0 +1,40 @@
package com.walkguide.service;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final ActivityLogService activityLogService;
public Map<String, Object> getProfile(Long userId) {
var user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User tidak ditemukan"));
return Map.of(
"id", user.getId(),
"email", user.getEmail(),
"displayName", user.getDisplayName() != null ? user.getDisplayName() : "",
"role", user.getRole(),
"uniqueUserId", user.getUniqueUserId() != null ? user.getUniqueUserId() : ""
);
}
public void logWalkGuideStart(Long userId) {
userRepository.findById(userId).ifPresent(user ->
activityLogService.createLog(user, ActivityLogType.WALKGUIDE_START,
"WalkGuide dimulai", null));
}
public void logWalkGuideStop(Long userId) {
userRepository.findById(userId).ifPresent(user ->
activityLogService.createLog(user, ActivityLogType.WALKGUIDE_STOP,
"WalkGuide dihentikan", null));
}
}
@@ -52,6 +52,13 @@ public class VoiceCommandService {
.collect(Collectors.toList());
}
public List<VoiceCommandResponse> getAllForGuardian(Long guardianId) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
return getAll(pairing.getUser().getId());
}
public VoiceCommandResponse updateByGuardian(Long guardianId, VoiceCommandUpdateRequest req) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
@@ -3,68 +3,49 @@ package com.walkguide.websocket;
import com.walkguide.dto.response.LocationResponse;
import com.walkguide.dto.response.NotificationResponse;
import com.walkguide.dto.response.SosEventResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
/**
* Service untuk broadcast pesan real-time via WebSocket (STOMP).
*
* Dipakai oleh:
* - LocationService → broadcast GPS ke Guardian
* - SosService → broadcast SOS ke Guardian
* - NotificationService→ broadcast notif ke User
*
* PATTERN: Observer — Guardian/User subscribe ke topic,
* LocationBroadcaster push data saat ada update.
*/
import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class LocationBroadcaster {
private static final Logger log = LoggerFactory.getLogger(LocationBroadcaster.class);
private final SimpMessagingTemplate messagingTemplate;
/**
* Broadcast lokasi GPS user ke Guardian yang subscribe.
* Guardian Flutter subscribe ke: /topic/location/{userId}
*
* @param userId ID dari ROLE_USER (bukan guardian)
* @param location Response lokasi terbaru
*/
public LocationBroadcaster(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public void broadcastLocation(Long userId, LocationResponse location) {
String destination = "/topic/location/" + userId;
messagingTemplate.convertAndSend(destination, location);
log.debug("[WS] Location broadcast → {} | lat={} lng={}",
log.debug("[WS] Location broadcast -> {} | lat={} lng={}",
destination, location.getLat(), location.getLng());
}
/**
* Broadcast SOS event ke Guardian secara real-time.
* Guardian Flutter subscribe ke: /queue/sos/{guardianId}
*
* @param guardianId ID dari ROLE_GUARDIAN
* @param sos SOS event yang baru di-trigger
*/
public void broadcastSos(Long guardianId, SosEventResponse sos) {
String destination = "/queue/sos/" + guardianId;
messagingTemplate.convertAndSend(destination, sos);
log.info("[WS] SOS broadcast → {} | userId={} status={}",
log.info("[WS] SOS broadcast -> {} | userId={} status={}",
destination, sos.getUserId(), sos.getStatus());
}
/**
* Broadcast notifikasi dari Guardian ke User secara real-time.
* User Flutter subscribe ke: /queue/notif/{userId}
*
* @param userId ID dari ROLE_USER yang menerima notif
* @param notification Notifikasi yang baru dikirim Guardian
*/
public void broadcastNotification(Long userId, NotificationResponse notification) {
String destination = "/queue/notif/" + userId;
messagingTemplate.convertAndSend(destination, notification);
log.debug("[WS] Notification broadcast → {} | type={}",
log.debug("[WS] Notification broadcast -> {} | type={}",
destination, notification.getNotifType());
}
public void broadcastCall(Long receiverId, Map<String, String> payload) {
String destination = "/queue/call/" + receiverId;
messagingTemplate.convertAndSend(destination, payload);
log.info("[WS] Call broadcast -> {} | type={} status={} channel={}",
destination, payload.get("type"), payload.get("status"), payload.get("channelName"));
}
}
@@ -1,35 +1,9 @@
# ===================================================
# Profile: prod (production)
# Aktifkan dengan: --spring.profiles.active=prod
# Semua nilai WAJIB diisi via environment variable
# Tidak ada default value — akan gagal start jika kosong
# ===================================================
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
show-sql: false
properties:
hibernate:
format_sql: false
server:
port: ${PORT:8080}
jwt:
secret: ${JWT_SECRET}
expiration: ${JWT_EXPIRATION:86400000}
agora:
app-id: ${AGORA_APP_ID}
app-certificate: ${AGORA_APP_CERTIFICATE}
logging:
level:
com.walkguide: INFO
org.springframework.messaging: WARN
org.springframework.web.socket: WARN
DB_URL=jdbc:postgresql://<host>:<port>/<database>
DB_USERNAME=<database_username>
DB_PASSWORD=<database_password>
JWT_SECRET=<base64_hs256_secret_at_least_32_bytes>
JWT_EXPIRATION=86400000
AGORA_APP_ID=<agora_app_id>
AGORA_APP_CERTIFICATE=<agora_app_certificate>
FIREBASE_CREDENTIALS_PATH=classpath:firebase/google-services-admin.json
FIREBASE_NOTIFICATIONS_COLLECTION=notifications
@@ -4,11 +4,20 @@
# atau set env: SPRING_PROFILES_ACTIVE=dev
# ===================================================
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
username: ${DB_USERNAME:5803024001}
password: ${DB_PASSWORD:pw5803024001}
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: ${DB_POOL_MAX:1}
minimum-idle: ${DB_POOL_MIN_IDLE:0}
connection-timeout: ${DB_CONNECTION_TIMEOUT:10000}
idle-timeout: ${DB_IDLE_TIMEOUT:30000}
max-lifetime: ${DB_MAX_LIFETIME:120000}
flyway:
enabled: ${FLYWAY_ENABLED:true}
jpa:
show-sql: true
@@ -16,16 +25,16 @@ spring:
hibernate:
format_sql: true
jwt:
secret: ${JWT_SECRET:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970}
expiration: 86400000
jwt:
secret: ${JWT_SECRET}
expiration: ${JWT_EXPIRATION:86400000}
agora:
app-id: ${AGORA_APP_ID:}
app-certificate: ${AGORA_APP_CERTIFICATE:}
agora:
app-id: ${AGORA_APP_ID:}
app-certificate: ${AGORA_APP_CERTIFICATE:}
logging:
level:
com.walkguide: DEBUG
org.springframework.messaging: DEBUG
org.springframework.web.socket: DEBUG
org.springframework.web.socket: DEBUG
@@ -1,11 +1,19 @@
# ===== SERVER =====
spring.config.import=optional:file:./secrets.properties,optional:file:walkguide-backend/demo/secrets.properties
server.port=${SERVER_PORT:8080}
server.address=${SERVER_ADDRESS:0.0.0.0}
# ===== POSTGRESQL CONNECTION =====
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
# ===== HIKARI POOL (keep DB classroom slots low) =====
spring.datasource.hikari.maximum-pool-size=${DB_POOL_MAX:1}
spring.datasource.hikari.minimum-idle=${DB_POOL_MIN_IDLE:0}
spring.datasource.hikari.connection-timeout=${DB_CONNECTION_TIMEOUT:10000}
spring.datasource.hikari.idle-timeout=${DB_IDLE_TIMEOUT:30000}
spring.datasource.hikari.max-lifetime=${DB_MAX_LIFETIME:120000}
# ===== JPA / HIBERNATE =====
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
@@ -30,6 +38,10 @@ springdoc.api-docs.path=/v3/api-docs
agora.app-id=${AGORA_APP_ID:}
agora.app-certificate=${AGORA_APP_CERTIFICATE:}
# ===== FIREBASE =====
firebase.credentials-path=${FIREBASE_CREDENTIALS_PATH:classpath:firebase/google-services-admin.json}
firebase.notifications-collection=${FIREBASE_NOTIFICATIONS_COLLECTION:notifications}
# ===== WEBSOCKET =====
# WebSocket auto-dikonfigurasi oleh WebSocketConfig.java
@@ -38,4 +50,4 @@ logging.level.com.walkguide=DEBUG
logging.level.org.springframework.messaging=INFO
logging.level.org.springframework.web.socket=INFO
spring.profiles.active=dev
spring.profiles.active=dev
@@ -0,0 +1,9 @@
-- V17: Expiring pairing code for lecturer revision.
-- unique_user_id remains a stable account identifier; pairing_code is temporary.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS pairing_code VARCHAR(8) UNIQUE,
ADD COLUMN IF NOT EXISTS pairing_code_expires_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_users_pairing_code ON users(pairing_code);
CREATE INDEX IF NOT EXISTS idx_users_pairing_code_expires_at ON users(pairing_code_expires_at);
@@ -0,0 +1,2 @@
ALTER TABLE location_history
ADD COLUMN IF NOT EXISTS battery_level INTEGER;
@@ -4,7 +4,8 @@ info:
version: 1.0.0
description: Design contract for WalkGuide Flutter and Spring Boot integration.
servers:
- url: http://localhost:8080/api/v1
- url: https://api.walkguide.example/api/v1
description: Production deployment URL placeholder
security:
- bearerAuth: []
components:
@@ -37,9 +38,16 @@ components:
role: { type: string, enum: [USER, GUARDIAN] }
PairingInviteRequest:
type: object
required: [uniqueUserId]
required: [pairingCode]
properties:
uniqueUserId: { type: string, minLength: 12, maxLength: 12 }
pairingCode: { type: string, minLength: 8, maxLength: 8, description: "Temporary code generated by the User app; expires automatically." }
uniqueUserId: { type: string, minLength: 12, maxLength: 12, deprecated: true }
PairingCodeResponse:
type: object
properties:
pairingCode: { type: string }
expiresAt: { type: string, format: date-time }
expiresInSeconds: { type: integer, format: int64 }
PairingRespondRequest:
type: object
required: [pairingId, accept]
@@ -107,6 +115,14 @@ paths:
schema: { $ref: "#/components/schemas/PairingInviteRequest" }
responses:
"200": { description: Invite sent }
/shared/pairing/code:
get:
responses:
"200": { description: Active expiring pairing code }
/shared/pairing/code/regenerate:
post:
responses:
"200": { description: New expiring pairing code generated }
/shared/pairing/respond:
post:
requestBody:
@@ -257,6 +273,15 @@ paths:
schema: { type: integer, format: int64 }
responses:
"200": { description: SOS acknowledged }
/guardian/sos/{id}/resolve:
put:
parameters:
- in: path
name: id
required: true
schema: { type: integer, format: int64 }
responses:
"200": { description: SOS handled and resolved }
/guardian/ai-config:
get:
responses:
@@ -275,6 +300,9 @@ paths:
get:
responses:
"200": { description: Paired user shortcuts }
put:
responses:
"200": { description: Paired user shortcut updated }
/guardian/geofence:
get:
responses:
@@ -8,10 +8,10 @@ import org.springframework.boot.test.mock.mockito.MockBean;
@SpringBootTest(properties = {
"spring.datasource.url=jdbc:postgresql://localhost:5432/walkguide_test",
"spring.datasource.username=test",
"spring.datasource.password=test",
"spring.datasource.password=${TEST_DB_PASSWORD}",
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=none",
"jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970"
"jwt.secret=${TEST_JWT_SECRET}"
})
class DemoApplicationTests {
@@ -4,13 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.dto.request.CallTokenRequest;
import com.walkguide.dto.response.AgoraTokenResponse;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.JwtAuthFilter;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.AgoraTokenService;
import com.walkguide.service.FcmService;
import org.junit.jupiter.api.BeforeEach;
import com.walkguide.service.CallNotificationService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
@@ -18,19 +15,22 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import com.walkguide.security.JwtAuthFilter;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc(addFilters = false)
@WebMvcTest(CallController.class)
@@ -38,7 +38,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@DisplayName("CallController Unit Tests")
class CallControllerTest {
@MockBean private JwtAuthFilter jwtAuthFilter;
@MockBean
private JwtAuthFilter jwtAuthFilter;
@Autowired
private MockMvc mockMvc;
@@ -50,35 +51,10 @@ class CallControllerTest {
private AgoraTokenService agoraTokenService;
@MockBean
private FcmService fcmService;
@MockBean
private UserRepository userRepository;
private User sampleCaller;
private User sampleReceiver;
@BeforeEach
void setUp() {
sampleCaller = User.builder()
.id(1L)
.email("caller@test.com")
.displayName("Caller User")
.fcmToken("fcm-caller-token")
.build();
sampleReceiver = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver Guardian")
.fcmToken("fcm-receiver-token")
.build();
}
// ===== GENERATE TOKEN =====
private CallNotificationService callNotificationService;
@Test
@DisplayName("POST /api/v1/shared/call/token - valid request harus return Agora token")
@DisplayName("POST /api/v1/shared/call/token - valid request returns Agora token")
void generateToken_validRequest_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
@@ -88,7 +64,7 @@ class CallControllerTest {
AgoraTokenResponse tokenResp = AgoraTokenResponse.builder()
.token("agora-rtc-token-xyz")
.channelName("call_1_2_1234567890")
.channelName("call_1_2")
.uid(1001)
.build();
@@ -102,34 +78,24 @@ class CallControllerTest {
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Token Agora berhasil digenerate"))
.andExpect(jsonPath("$.data.token").value("agora-rtc-token-xyz"))
.andExpect(jsonPath("$.data.channelName").value("call_1_2_1234567890"));
verify(agoraTokenService).generateToken(1L, 2L);
.andExpect(jsonPath("$.data.channelName").value("call_1_2"));
}
}
@Test
@DisplayName("POST /api/v1/shared/call/token - receiverId null harus return 400")
@DisplayName("POST /api/v1/shared/call/token - null receiverId returns 400")
void generateToken_nullReceiverId_shouldReturn400() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
CallTokenRequest req = new CallTokenRequest();
// CallTokenRequest dengan receiverId null — @Valid harus menolak
CallTokenRequest req = new CallTokenRequest();
// receiverId tidak di-set (null)
mockMvc.perform(post("/api/v1/shared/call/token")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
verify(agoraTokenService, never()).generateToken(anyLong(), anyLong());
}
mockMvc.perform(post("/api/v1/shared/call/token")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("POST /api/v1/shared/call/token - service throw harus return 500")
@DisplayName("POST /api/v1/shared/call/token - service error returns 500")
void generateToken_serviceThrows_shouldReturn500() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
@@ -148,21 +114,17 @@ class CallControllerTest {
}
}
// ===== NOTIFY CALL =====
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver punya FCM token harus kirim notifikasi")
void notifyCall_receiverHasFcmToken_shouldReturn200AndSendFcm() throws Exception {
@DisplayName("POST /api/v1/shared/call/notify - delegates to call notification service")
void notifyCall_validRequest_shouldReturnServiceMessage() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
when(callNotificationService.notifyIncomingCall(eq(1L), any(CallNotifyRequest.class)))
.thenReturn("Notifikasi panggilan berhasil dikirim");
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("call_1_2_1234567890");
req.setChannelName("call_1_2");
req.setAgoraToken("agora-token-xyz");
req.setReceiverUid(1002);
@@ -174,260 +136,40 @@ class CallControllerTest {
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Notifikasi panggilan berhasil dikirim"));
verify(fcmService).sendHighPriority(
eq("fcm-receiver-token"),
eq("📞 Panggilan Masuk"),
contains("Caller User"),
anyMap()
);
verify(callNotificationService).notifyIncomingCall(eq(1L), any(CallNotifyRequest.class));
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver tidak punya FCM token harus return 200 tanpa FCM")
void notifyCall_receiverNoFcmToken_shouldReturn200WithWarningMessage() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
@DisplayName("POST /api/v1/shared/call/notify - validation failure does not call service")
void notifyCall_invalidRequest_shouldReturn400() throws Exception {
CallNotifyRequest req = new CallNotifyRequest();
req.setChannelName("call_1_2");
User receiverNoFcm = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver")
.fcmToken(null) // tidak punya FCM token
.build();
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiverNoFcm));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("call_1_2_abc");
req.setAgoraToken("token-xyz");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
// FCM tidak boleh dipanggil karena tidak ada token
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
verify(callNotificationService, never()).notifyIncomingCall(any(), any());
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver FCM token blank harus return 200 tanpa FCM")
void notifyCall_receiverBlankFcmToken_shouldReturn200WithoutFcm() throws Exception {
@DisplayName("POST /api/v1/shared/call/end - delegates to call notification service")
void endCall_validOtherId_shouldDelegateToService() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User receiverBlankFcm = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver")
.fcmToken(" ") // blank
.build();
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiverBlankFcm));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - caller tidak ditemukan harus return 404")
void notifyCall_callerNotFound_shouldReturn404() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(1L))
.thenThrow(new ResourceNotFoundException("Caller not found"));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound());
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver tidak ditemukan harus return 404")
void notifyCall_receiverNotFound_shouldReturn404() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L))
.thenThrow(new ResourceNotFoundException("Receiver not found"));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - caller displayName null harus pakai email sebagai pengganti")
void notifyCall_callerNoDisplayName_shouldUseEmailAsFallback() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User callerNoName = User.builder()
.id(1L)
.email("noreply@test.com")
.displayName(null) // tidak ada displayName
.build();
when(userRepository.findById(1L)).thenReturn(Optional.of(callerNoName));
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk());
// Pastikan body notifikasi menggunakan email sebagai fallback
verify(fcmService).sendHighPriority(
anyString(),
anyString(),
contains("noreply@test.com"),
anyMap()
);
}
}
// ===== END CALL =====
@Test
@DisplayName("POST /api/v1/shared/call/end - otherId valid dan punya FCM token harus kirim notifikasi berakhir")
void endCall_otherHasFcmToken_shouldSendEndNotification() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendToToken(anyString(), anyString(), anyString(), anyMap());
Map<String, Long> body = Map.of("otherId", 2L);
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.content(objectMapper.writeValueAsString(Map.of("otherId", 2L))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService).sendToToken(
eq("fcm-receiver-token"),
eq("Panggilan Berakhir"),
eq("Panggilan telah berakhir"),
anyMap()
);
}
}
@Test
@DisplayName("POST /api/v1/shared/call/end - otherId null harus return 200 tanpa kirim FCM")
void endCall_nullOtherId_shouldReturn200WithoutFcm() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
Map<String, Long> body = Map.of(); // tidak ada otherId
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/end - other tidak punya FCM token harus return 200 tanpa FCM")
void endCall_otherNoFcmToken_shouldReturn200WithoutFcm() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User otherNoFcm = User.builder()
.id(2L)
.email("other@test.com")
.fcmToken(null)
.build();
when(userRepository.findById(2L)).thenReturn(Optional.of(otherNoFcm));
Map<String, Long> body = Map.of("otherId", 2L);
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@WithMockUser(username = "2", roles = "GUARDIAN")
@DisplayName("POST /api/v1/shared/call/end - Guardian juga bisa end call")
void endCall_asGuardian_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
doNothing().when(fcmService).sendToToken(anyString(), anyString(), anyString(), anyMap());
Map<String, Long> body = Map.of("otherId", 1L);
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
verify(callNotificationService).notifyCallEnded(1L, 2L);
}
}
}
@@ -211,6 +211,24 @@ class GuardianControllerTest {
}
}
@Test
@DisplayName("PUT /api/v1/guardian/sos/{id}/resolve - harus tandai SOS ditangani")
void resolveSos_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
SosEventResponse resp = new SosEventResponse();
when(sosService.resolveSos(2L, 10L)).thenReturn(resp);
mockMvc.perform(put("/api/v1/guardian/sos/10/resolve")
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("SOS ditangani"));
verify(sosService).resolveSos(2L, 10L);
}
}
// ===== AI CONFIG =====
@Test
@@ -254,7 +272,7 @@ class GuardianControllerTest {
void getVoiceCommands_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
when(voiceCommandService.getAll(2L)).thenReturn(List.of());
when(voiceCommandService.getAllForGuardian(2L)).thenReturn(List.of());
mockMvc.perform(get("/api/v1/guardian/voice-commands"))
.andExpect(status().isOk())
@@ -288,7 +306,7 @@ class GuardianControllerTest {
void getShortcuts_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
when(hardwareShortcutService.getAll(2L)).thenReturn(List.of());
when(hardwareShortcutService.getAllForGuardian(2L)).thenReturn(List.of());
mockMvc.perform(get("/api/v1/guardian/shortcuts"))
.andExpect(status().isOk())
@@ -296,6 +314,27 @@ class GuardianControllerTest {
}
}
@Test
@DisplayName("PUT /api/v1/guardian/shortcuts - harus update shortcut user yang dipair")
void updateShortcut_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest();
HardwareShortcutResponse resp = new HardwareShortcutResponse();
when(hardwareShortcutService.updateByGuardian(eq(2L), any())).thenReturn(resp);
mockMvc.perform(put("/api/v1/guardian/shortcuts")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Hardware shortcut diperbarui"));
verify(hardwareShortcutService).updateByGuardian(eq(2L), any(HardwareShortcutUpdateRequest.class));
}
}
// ===== GEOFENCE =====
@Test
@@ -92,9 +92,9 @@ class PairingControllerTest {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
InviteUserRequest req = new InviteUserRequest();
req.setUniqueUserId("INVALID999");
req.setPairingCode("BAD99999");
when(pairingService.inviteUser(2L, "INVALID999"))
when(pairingService.inviteUser(2L, "BAD99999"))
.thenThrow(new RuntimeException("User dengan ID tersebut tidak ditemukan"));
mockMvc.perform(post("/api/v1/shared/pairing/invite")
@@ -1,7 +1,5 @@
package com.walkguide.integration;
import org.junit.jupiter.api.Disabled;
import com.fasterxml.jackson.databind.JsonNode;
import com.walkguide.dto.request.LoginRequest;
import com.walkguide.dto.request.RefreshTokenRequest;
@@ -25,8 +23,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
*/
@DisplayName("Integration Test — Auth Flow (Testcontainers)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Disabled("Requires Docker/Testcontainers")
class AuthIntegrationTest extends AbstractIntegrationTest {
class AuthIntegrationTest extends AbstractIntegrationTest {
private static final String USER_EMAIL = "integ_user@walkguide.test";
private static final String USER_PASS = "password123";
@@ -294,4 +291,4 @@ class AuthIntegrationTest extends AbstractIntegrationTest {
assertThat(result.getResponse().getStatus())
.isBetween(200, 399));
}
}
}
@@ -1,7 +1,5 @@
package com.walkguide.integration;
import org.junit.jupiter.api.Disabled;
import com.fasterxml.jackson.databind.JsonNode;
import com.walkguide.dto.request.InviteUserRequest;
import com.walkguide.dto.request.PairingResponseRequest;
@@ -26,8 +24,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
*/
@DisplayName("Integration Test — Pairing Flow (Testcontainers)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Disabled("Requires Docker/Testcontainers")
class PairingIntegrationTest extends AbstractIntegrationTest {
class PairingIntegrationTest extends AbstractIntegrationTest {
private static final String USER_EMAIL = "pairing_user@walkguide.test";
private static final String USER_PASS = "userpass123";
@@ -286,4 +283,4 @@ class PairingIntegrationTest extends AbstractIntegrationTest {
mockMvc.perform(get("/api/v1/shared/pairing/status"))
.andExpect(status().isForbidden());
}
}
}
@@ -1,7 +1,5 @@
package com.walkguide.integration;
import org.junit.jupiter.api.Disabled;
import com.fasterxml.jackson.databind.JsonNode;
import com.walkguide.dto.request.*;
import org.junit.jupiter.api.*;
@@ -28,8 +26,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
*/
@DisplayName("Integration Test — User Core Features (Testcontainers)")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Disabled("Requires Docker/Testcontainers")
class UserFeatureIntegrationTest extends AbstractIntegrationTest {
class UserFeatureIntegrationTest extends AbstractIntegrationTest {
private static final String USER_EMAIL = "feature_user@walkguide.test";
private static final String USER_PASS = "userpass123";
@@ -488,4 +485,4 @@ class UserFeatureIntegrationTest extends AbstractIntegrationTest {
.header("Authorization", bearerToken(userToken)))
.andExpect(status().isForbidden());
}
}
}
@@ -0,0 +1,132 @@
package com.walkguide.service;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import com.walkguide.websocket.LocationBroadcaster;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("CallNotificationService Unit Tests")
class CallNotificationServiceTest {
@Mock
private FcmService fcmService;
@Mock
private UserRepository userRepository;
@Mock
private LocationBroadcaster locationBroadcaster;
@InjectMocks
private CallNotificationService service;
private User caller;
private User receiver;
private CallNotifyRequest request;
@BeforeEach
void setUp() {
caller = User.builder()
.id(1L)
.email("caller@test.com")
.displayName("Caller User")
.build();
receiver = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver")
.fcmToken("receiver-token")
.build();
request = new CallNotifyRequest();
request.setReceiverId(2L);
request.setChannelName("call_1_2");
request.setAgoraToken("agora-token");
request.setReceiverUid(1002);
}
@Test
@DisplayName("notifyIncomingCall sends high priority FCM when receiver has token")
void notifyIncomingCall_receiverHasToken_shouldSendFcm() {
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
String message = service.notifyIncomingCall(1L, request);
assertEquals("Notifikasi panggilan berhasil dikirim", message);
verify(fcmService).sendHighPriority(
eq("receiver-token"),
eq("Panggilan Masuk"),
contains("Caller User"),
anyMap()
);
}
@Test
@DisplayName("notifyIncomingCall skips FCM when receiver token is missing")
void notifyIncomingCall_receiverHasNoToken_shouldSkipFcm() {
receiver.setFcmToken(null);
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
String message = service.notifyIncomingCall(1L, request);
assertEquals("Panggilan dikirim via realtime fallback.", message);
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
@Test
@DisplayName("notifyIncomingCall throws 404 when caller is missing")
void notifyIncomingCall_missingCaller_shouldThrow() {
when(userRepository.findById(1L)).thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class, () -> service.notifyIncomingCall(1L, request));
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
@Test
@DisplayName("notifyCallEnded sends FCM when other user has token")
void notifyCallEnded_otherHasToken_shouldSendFcm() {
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
service.notifyCallEnded(1L, 2L);
verify(fcmService).sendToToken(
eq("receiver-token"),
eq("Panggilan Berakhir"),
eq("Panggilan telah berakhir"),
anyMap()
);
}
@Test
@DisplayName("notifyCallEnded skips repository lookup when otherId is null")
void notifyCallEnded_nullOtherId_shouldReturn() {
service.notifyCallEnded(1L, null);
verify(userRepository, never()).findById(2L);
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}
@@ -1,11 +1,15 @@
package com.walkguide.service;
import com.walkguide.dto.request.HardwareShortcutUpdateRequest;
import com.walkguide.dto.response.HardwareShortcutResponse;
import com.walkguide.entity.HardwareShortcut;
import com.walkguide.enums.HardwareShortcutKey;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.HardwareShortcutRepository;
import com.walkguide.dto.request.HardwareShortcutUpdateRequest;
import com.walkguide.dto.response.HardwareShortcutResponse;
import com.walkguide.entity.PairingRelation;
import com.walkguide.entity.HardwareShortcut;
import com.walkguide.entity.User;
import com.walkguide.enums.HardwareShortcutKey;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.HardwareShortcutRepository;
import com.walkguide.repository.PairingRelationRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -15,7 +19,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
@@ -25,8 +30,10 @@ import static org.mockito.Mockito.*;
@DisplayName("HardwareShortcutService Unit Tests")
class HardwareShortcutServiceTest {
@Mock
HardwareShortcutRepository hardwareShortcutRepository;
@Mock
HardwareShortcutRepository hardwareShortcutRepository;
@Mock
PairingRelationRepository pairingRelationRepository;
@InjectMocks
HardwareShortcutService hardwareShortcutService;
@@ -173,11 +180,36 @@ class HardwareShortcutServiceTest {
@Test
@DisplayName("update - shortcutKey enum invalid harus throw IllegalArgumentException")
void update_invalidEnumKey_shouldThrow() {
void update_invalidEnumKey_shouldThrow() {
HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest();
req.setShortcutKey("INVALID_KEY_XYZ");
assertThatThrownBy(() -> hardwareShortcutService.update(10L, req))
.isInstanceOf(IllegalArgumentException.class);
}
}
assertThatThrownBy(() -> hardwareShortcutService.update(10L, req))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("updateByGuardian - harus update shortcut milik user yang dipair")
void updateByGuardian_shouldUpdatePairedUserShortcut() {
HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest();
req.setShortcutKey("CALL_GUARDIAN");
req.setButtonName("Power Double Tap");
User guardian = User.builder().id(1L).build();
User user = User.builder().id(10L).build();
PairingRelation pairing = PairingRelation.builder()
.guardian(guardian).user(user).status(PairingStatus.ACTIVE).build();
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(pairing));
when(hardwareShortcutRepository.findByUserId(10L))
.thenReturn(List.of(shortcutCallGuardian));
when(hardwareShortcutRepository.save(any(HardwareShortcut.class)))
.thenAnswer(inv -> inv.getArgument(0));
HardwareShortcutResponse result = hardwareShortcutService.updateByGuardian(1L, req);
assertThat(result.getButtonName()).isEqualTo("Power Double Tap");
verify(hardwareShortcutRepository).findByUserId(10L);
}
}
@@ -5,8 +5,9 @@ import com.walkguide.dto.response.LocationResponse;
import com.walkguide.entity.GeofenceConfig;
import com.walkguide.entity.LocationHistory;
import com.walkguide.entity.PairingRelation;
import com.walkguide.entity.User;
import com.walkguide.enums.PairingStatus;
import com.walkguide.entity.User;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster;
import org.junit.jupiter.api.BeforeEach;
@@ -217,9 +218,9 @@ class LocationServiceTest {
// ===== getLocationHistory TESTS =====
@Test
@DisplayName("getLocationHistory - harus return halaman lokasi sesuai pageable")
void getLocationHistory_shouldReturnPagedLocations() {
@Test
@DisplayName("getLocationHistory - harus return halaman lokasi sesuai pageable")
void getLocationHistory_shouldReturnPagedLocations() {
Pageable pageable = PageRequest.of(0, 10);
Page<LocationHistory> page = new PageImpl<>(List.of(sampleLocation), pageable, 1);
@@ -229,10 +230,39 @@ class LocationServiceTest {
assertThat(result).isNotNull();
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257);
}
// ===== haversineMeters TESTS =====
assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257);
}
@Test
@DisplayName("getLocationHistoryForGuardian - harus ambil histori milik User yang dipair, bukan guardian")
void getLocationHistoryForGuardian_activePairing_shouldUsePairedUserId() {
Pageable pageable = PageRequest.of(0, 10);
Page<LocationHistory> page = new PageImpl<>(List.of(sampleLocation), pageable, 1);
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
when(locationHistoryRepository.findByUserIdOrderByCreatedAtDesc(2L, pageable)).thenReturn(page);
Page<LocationResponse> result = locationService.getLocationHistoryForGuardian(1L, pageable);
assertThat(result.getContent()).hasSize(1);
verify(locationHistoryRepository).findByUserIdOrderByCreatedAtDesc(2L, pageable);
verify(locationHistoryRepository, never()).findByUserIdOrderByCreatedAtDesc(1L, pageable);
}
@Test
@DisplayName("getLocationHistoryForGuardian - tanpa pairing aktif harus error pairing")
void getLocationHistoryForGuardian_noPairing_shouldThrow() {
Pageable pageable = PageRequest.of(0, 10);
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> locationService.getLocationHistoryForGuardian(1L, pageable))
.isInstanceOf(PairingException.class)
.hasMessageContaining("Guardian belum terhubung");
}
// ===== haversineMeters TESTS =====
@Test
@DisplayName("haversineMeters - titik sama: jarak harus 0")
@@ -256,4 +286,4 @@ class LocationServiceTest {
double d2 = LocationService.haversineMeters(-7.300, 112.800, -7.257, 112.752);
assertThat(d1).isCloseTo(d2, within(0.001));
}
}
}
@@ -15,7 +15,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import java.util.Optional;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@@ -52,10 +53,12 @@ class PairingServiceTest {
.id(2L)
.email("user@test.com")
.role("ROLE_USER")
.displayName("User Test")
.uniqueUserId("ABC123DEF456")
.fcmToken("user-fcm-token")
.build();
.displayName("User Test")
.uniqueUserId("ABC123DEF456")
.pairingCode("AB12CD34")
.pairingCodeExpiresAt(LocalDateTime.now().plusMinutes(10))
.fcmToken("user-fcm-token")
.build();
}
// ===== INVITE USER TESTS =====
@@ -66,7 +69,7 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("ABC123DEF456")).thenReturn(Optional.of(user));
when(userRepository.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.save(any(PairingRelation.class))).thenAnswer(inv -> {
PairingRelation p = inv.getArgument(0);
@@ -74,7 +77,7 @@ class PairingServiceTest {
return p;
});
PairingStatusResponse result = pairingService.inviteUser(1L, "ABC123DEF456");
PairingStatusResponse result = pairingService.inviteUser(1L, "AB12CD34");
assertThat(result).isNotNull();
verify(pairingRelationRepository).save(any(PairingRelation.class));
@@ -113,7 +116,7 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("INVALID")).thenReturn(Optional.empty());
when(userRepository.findByPairingCode("INVALID")).thenReturn(Optional.empty());
assertThatThrownBy(() -> pairingService.inviteUser(1L, "INVALID"))
.isInstanceOf(ResourceNotFoundException.class)
@@ -123,17 +126,20 @@ class PairingServiceTest {
@Test
@DisplayName("inviteUser - target bukan ROLE_USER harus throw PairingException")
void inviteUser_targetNotUser_shouldThrow() {
User anotherGuardian = User.builder()
.id(3L).role("ROLE_GUARDIAN").uniqueUserId("GRD000000001").build();
User anotherGuardian = User.builder()
.id(3L).role("ROLE_GUARDIAN")
.pairingCode("GRD00001")
.pairingCodeExpiresAt(LocalDateTime.now().plusMinutes(10))
.build();
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("GRD000000001")).thenReturn(Optional.of(anotherGuardian));
assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD000000001"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("bukan milik User");
when(userRepository.findByPairingCode("GRD00001")).thenReturn(Optional.of(anotherGuardian));
assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD00001"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("bukan milik User");
}
@Test
@@ -142,12 +148,12 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("ABC123DEF456")).thenReturn(Optional.of(user));
when(userRepository.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(true);
assertThatThrownBy(() -> pairingService.inviteUser(1L, "ABC123DEF456"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("sudah dipair dengan Guardian lain");
assertThatThrownBy(() -> pairingService.inviteUser(1L, "AB12CD34"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("sudah dipair dengan Guardian lain");
}
// ===== RESPOND TO PAIRING TESTS =====
@@ -211,4 +217,4 @@ class PairingServiceTest {
.isInstanceOf(PairingException.class)
.hasMessageContaining("sudah direspons");
}
}
}
@@ -4,10 +4,11 @@ import com.walkguide.dto.request.SosRequest;
import com.walkguide.dto.response.SosEventResponse;
import com.walkguide.entity.PairingRelation;
import com.walkguide.entity.SosEvent;
import com.walkguide.entity.User;
import com.walkguide.enums.PairingStatus;
import com.walkguide.enums.SosStatus;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.entity.User;
import com.walkguide.enums.PairingStatus;
import com.walkguide.enums.SosStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster;
import org.junit.jupiter.api.BeforeEach;
@@ -79,10 +80,10 @@ class SosServiceTest {
req.setLat(-7.257);
req.setLng(112.752);
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty()); // tidak ada guardian → skip FCM
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
SosEventResponse result = sosService.triggerSos(2L, req);
@@ -103,10 +104,10 @@ class SosServiceTest {
req.setLat(-7.257);
req.setLng(112.752);
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty());
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class);
@@ -147,20 +148,37 @@ class SosServiceTest {
SosRequest req = new SosRequest();
req.setTriggerType("MANUAL");
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> sosService.triggerSos(99L, req))
.isInstanceOf(ResourceNotFoundException.class);
}
when(userRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> sosService.triggerSos(99L, req))
.isInstanceOf(ResourceNotFoundException.class);
}
@Test
@DisplayName("triggerSos - tanpa pairing aktif: harus throw PairingException dan tidak simpan SOS")
void triggerSos_unpaired_shouldThrowPairingException() {
SosRequest req = new SosRequest();
req.setTriggerType("MANUAL");
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> sosService.triggerSos(2L, req))
.isInstanceOf(PairingException.class)
.hasMessageContaining("Guardian aktif");
verify(sosEventRepository, never()).save(any(SosEvent.class));
}
// ===== acknowledgeSos TESTS =====
@Test
@DisplayName("acknowledgeSos - SOS ditemukan: harus ubah status ke ACKNOWLEDGED")
void acknowledgeSos_sosFound_shouldChangeStatusToAcknowledged() {
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty());
@@ -179,8 +197,10 @@ class SosServiceTest {
@Test
@DisplayName("acknowledgeSos - ada pairing user: harus kirim FCM ke user bahwa guardian sudah respon")
void acknowledgeSos_activePairingForUser_shouldNotifyUser() {
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
@@ -198,13 +218,35 @@ class SosServiceTest {
@Test
@DisplayName("acknowledgeSos - SOS tidak ditemukan: harus throw ResourceNotFoundException")
void acknowledgeSos_sosNotFound_shouldThrowException() {
when(sosEventRepository.findById(999L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> sosService.acknowledgeSos(1L, 999L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessageContaining("SOS event tidak ditemukan");
}
void acknowledgeSos_sosNotFound_shouldThrowException() {
when(sosEventRepository.findById(999L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> sosService.acknowledgeSos(1L, 999L))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessageContaining("SOS event tidak ditemukan");
}
@Test
@DisplayName("resolveSos - SOS ditemukan: harus ubah status ke RESOLVED")
void resolveSos_sosFound_shouldChangeStatusToResolved() {
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty());
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
SosEventResponse result = sosService.resolveSos(1L, 50L);
assertThat(result).isNotNull();
assertThat(result.getStatus()).isEqualTo("RESOLVED");
ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class);
verify(sosEventRepository).save(captor.capture());
assertThat(captor.getValue().getStatus()).isEqualTo(SosStatus.RESOLVED);
assertThat(captor.getValue().getAcknowledgedAt()).isNotNull();
}
// ===== getSosEvents TESTS =====
@@ -250,4 +292,4 @@ class SosServiceTest {
assertThatThrownBy(() -> sosService.getSosEventsForGuardian(1L, PageRequest.of(0, 10)))
.isInstanceOf(ResourceNotFoundException.class);
}
}
}
+10
View File
@@ -49,3 +49,13 @@ hs_err_pid*.log
# Android SDK path (generated by Android Studio)
android/local.properties
# Local Python/YOLO export artifacts - do not commit
.venv*/
yolov8n.onnx
yolov8n.pt
yolov8n_saved_model/
calibration_image_sample_data_*.npy
devtools_options.yaml
android/app/src/main/java/dev/flutter/plugins/
android/app/src/main/java/io/flutter/plugins/
@@ -5,6 +5,10 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
android {
namespace = "com.example.walkguide_app"
compileSdk = flutter.compileSdkVersion
@@ -31,6 +35,10 @@ android {
versionName = flutter.versionName
}
androidResources {
noCompress += listOf("tflite", "lite")
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
@@ -2,6 +2,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" />
@@ -13,7 +16,9 @@
<application
android:label="WalkGuide"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -34,6 +39,9 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
</application>
<queries>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
@@ -12,7 +12,7 @@
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">#F8FAFC</item>
</style>
</resources>
@@ -1,4 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.workers.max=2
org.gradle.parallel=false
org.gradle.daemon=false
android.useAndroidX=true
android.enableJetifier=true
kotlin.incremental=false
kotlin.incremental=false
@@ -21,6 +21,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
}
include(":app")
@@ -0,0 +1,36 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0B132B"/>
<stop offset="100%" stop-color="#1C2541"/>
</linearGradient>
<linearGradient id="cyan-glow" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#48CAE4"/>
<stop offset="100%" stop-color="#0077B6"/>
</linearGradient>
<linearGradient id="amber-glow" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFB703"/>
<stop offset="100%" stop-color="#FB8500"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#000000" flood-opacity="0.45"/>
</filter>
</defs>
<rect width="512" height="512" rx="115" fill="url(#bg-grad)"/>
<circle cx="256" cy="256" r="180" fill="none" stroke="#48CAE4" stroke-width="2" opacity="0.15" stroke-dasharray="10 14"/>
<circle cx="256" cy="256" r="110" fill="none" stroke="#48CAE4" stroke-width="3" opacity="0.3"/>
<path d="M 130 180 C 130 320, 180 370, 220 370 C 260 370, 256 270, 256 270"
fill="none"
stroke="url(#cyan-glow)"
stroke-width="52"
stroke-linecap="round"
filter="url(#shadow)"/>
<path d="M 382 180 C 382 320, 332 370, 292 370 C 252 370, 256 270, 256 270"
fill="none"
stroke="url(#amber-glow)"
stroke-width="52"
stroke-linecap="round"
filter="url(#shadow)"/>
<circle cx="256" cy="210" r="36" fill="#FFFFFF" filter="url(#shadow)"/>
<circle cx="256" cy="210" r="14" fill="#0B132B"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,10 +1,80 @@
person
bicycle
car
motorcycle
bicycle
airplane
bus
train
truck
chair
boat
traffic light
fire hydrant
stop sign
parking meter
bench
door
stairs
bird
cat
dog
horse
sheep
cow
elephant
bear
zebra
giraffe
backpack
umbrella
handbag
tie
suitcase
frisbee
skis
snowboard
sports ball
kite
baseball bat
baseball glove
skateboard
surfboard
tennis racket
bottle
wine glass
cup
fork
knife
spoon
bowl
banana
apple
sandwich
orange
broccoli
carrot
hot dog
pizza
donut
cake
chair
couch
potted plant
bed
dining table
toilet
tv
laptop
mouse
remote
keyboard
cell phone
microwave
oven
toaster
sink
refrigerator
book
clock
vase
scissors
teddy bear
hair drier
toothbrush
@@ -0,0 +1,16 @@
# WalkGuide Flutter Physical Device Benchmark Checklist
Use this file as the final evidence checklist. Do not replace these rows with emulator data.
| Metric | Command / Tool | Required Evidence |
|---|---|---|
| Cold start | `flutter run --profile --trace-startup` | `timeToFirstFrameMicros`, screenshot/log |
| Frame jank | DevTools Performance in profile mode | 90%+ frames under 16 ms |
| Memory baseline | DevTools Memory after app launch | Heap MB screenshot |
| Memory leak check | Navigate all major screens 10x | Heap growth before/after |
| CPU during YOLO | DevTools CPU profiler while WalkGuide active | Flame graph screenshot |
| API latency | Dio latency logs | p95 latency table |
| APK size | `flutter build apk --release --analyze-size` | Size analysis artifact |
| Delta table | Mid-sprint vs final | Week 5 vs final comparison |
Final benchmark must be captured on a physical Android phone in profile mode.
+156 -37
View File
@@ -1,56 +1,175 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'app_cubit.dart';
import 'router.dart';
import '../core/i18n/app_strings.dart';
import '../core/theme/app_colors.dart';
import '../core/theme/app_decorations.dart';
import '../core/theme/app_text_styles.dart';
class WalkGuideApp extends StatelessWidget {
const WalkGuideApp({super.key});
@override
Widget build(BuildContext context) {
const seed = Color(0xFF1A56DB);
const seed = AppColors.primaryBlue;
return BlocProvider(
create: (_) => AppCubit(),
child: MaterialApp.router(
title: 'WalkGuide',
debugShowCheckedModeBanner: false,
routerConfig: appRouter,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: seed),
scaffoldBackgroundColor: const Color(0xFFF8FAFC),
textTheme: GoogleFonts.interTextTheme(),
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: Colors.white,
foregroundColor: Color(0xFF0F172A),
elevation: 0,
surfaceTintColor: Colors.white,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: seed,
foregroundColor: Colors.white,
minimumSize: const Size(0, 46),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: BlocBuilder<AppCubit, AppState>(
builder: (context, state) => MaterialApp.router(
title: 'WalkGuide',
debugShowCheckedModeBanner: false,
routerConfig: appRouter,
builder: (context, child) {
final media = MediaQuery.of(context);
return MediaQuery(
data: media.copyWith(
textScaler: media.textScaler.clamp(
minScaleFactor: 0.9,
maxScaleFactor: 1.15,
),
),
child: child ?? const SizedBox.shrink(),
);
},
locale: state.localeCode == 'en-US'
? const Locale('en', 'US')
: const Locale('id', 'ID'),
supportedLocales: AppStrings.supportedLocales,
localizationsDelegates: const [
AppStringsDelegate(),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
theme: ThemeData(
useMaterial3: true,
visualDensity: VisualDensity.adaptivePlatformDensity,
colorScheme: ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
primary: seed,
secondary: AppColors.accent,
error: AppColors.danger,
),
scaffoldBackgroundColor: AppColors.surface,
textTheme: AppTextStyles.textTheme.apply(
bodyColor: AppColors.text,
displayColor: AppColors.text,
),
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
},
),
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: AppColors.surface,
foregroundColor: AppColors.text,
elevation: 0,
surfaceTintColor: Colors.transparent,
),
cardTheme: const CardThemeData(
elevation: 0,
color: AppColors.surfaceRaised,
surfaceTintColor: Colors.transparent,
margin: EdgeInsets.zero,
shape: AppDecorations.cardShape,
),
dividerTheme: const DividerThemeData(
color: AppColors.border,
thickness: 1,
space: 1,
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: AppColors.text,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: AppColors.border),
),
),
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
height: 76,
backgroundColor: Colors.white,
indicatorColor: AppColors.softBlueBg,
surfaceTintColor: Colors.transparent,
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
fontSize: 12,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w800
: FontWeight.w500,
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: seed,
foregroundColor: Colors.white,
minimumSize: const Size(0, 50),
textStyle: AppTextStyles.body.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 50),
foregroundColor: seed,
textStyle: AppTextStyles.body.copyWith(
color: seed,
fontWeight: FontWeight.w700,
),
side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.text,
contentTextStyle: AppTextStyles.body.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: seed, width: 1.5),
),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
),
),
),
);
}
@@ -4,14 +4,26 @@ class AppState {
final bool online;
final String? role;
final String? serverUrl;
final String localeCode;
const AppState({required this.online, this.role, this.serverUrl});
const AppState({
required this.online,
this.role,
this.serverUrl,
this.localeCode = 'id-ID',
});
AppState copyWith({bool? online, String? role, String? serverUrl}) {
AppState copyWith({
bool? online,
String? role,
String? serverUrl,
String? localeCode,
}) {
return AppState(
online: online ?? this.online,
role: role ?? this.role,
serverUrl: serverUrl ?? this.serverUrl,
localeCode: localeCode ?? this.localeCode,
);
}
}
@@ -25,5 +37,7 @@ class AppCubit extends Cubit<AppState> {
void setOnline(bool value) => emit(state.copyWith(online: value));
void setLocaleCode(String value) => emit(state.copyWith(localeCode: value));
void clearSession() => emit(const AppState(online: true));
}
@@ -1,60 +1,85 @@
import 'package:get_it/get_it.dart';
import 'package:flutter/foundation.dart';
import '../core/constants/app_constants.dart';
import '../core/ai/obstacle_alert_strategy.dart';
import '../core/ai/obstacle_analyzer.dart';
import '../core/ai/yolo_detector.dart';
import '../core/network/api_client.dart';
import '../core/services/haptic_service.dart';
import '../core/services/call_service.dart';
import '../core/services/fcm_service.dart';
import '../core/services/hardware_shortcut_listener.dart';
import '../core/services/incoming_call_polling_service.dart';
import '../core/services/location_reporter_service.dart';
import '../core/services/offline_queue_service.dart';
import '../core/services/stt_service.dart';
import '../core/services/tts_service.dart';
import '../core/services/voice_command_handler.dart';
import '../core/services/websocket_service.dart';
import '../core/storage/local_database.dart';
import '../core/storage/secure_storage.dart';
import '../features/notifications/application/notification_cubit.dart';
import '../features/notifications/data/repositories/notification_repository_impl.dart';
import '../features/notifications/domain/repositories/notification_repository.dart';
import '../features/sos/application/sos_cubit.dart';
import '../features/sos/data/repositories/sos_repository_impl.dart';
import '../features/sos/domain/repositories/sos_repository.dart';
import '../features/walk_guide/application/walk_guide_cubit.dart';
import '../features/walk_guide/data/repositories/walk_guide_repository_impl.dart';
import '../features/walk_guide/domain/repositories/walk_guide_repository.dart';
final sl = GetIt.instance;
Future<void> initDependencies() async {
sl.registerLazySingleton<SecureStorage>(() => SecureStorage());
sl.registerLazySingleton<LocalDatabase>(() => LocalDatabase());
sl.registerLazySingleton<ApiClient>(() => ApiClient(sl<SecureStorage>()));
sl.registerLazySingleton<TtsService>(() => TtsService());
sl.registerLazySingleton<SttService>(() => SttService());
sl.registerLazySingleton<HapticService>(() => HapticService());
sl.registerLazySingleton<ObstacleAlertStrategy>(
() => TtsWithHapticObstacleAlertStrategy(
sl<TtsService>(), sl<HapticService>()),
);
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>()));
sl.registerLazySingleton<OfflineQueueService>(() => OfflineQueueService());
sl.registerLazySingleton<YoloDetector>(
() => YoloDetector(sl<ObstacleAnalyzer>()));
sl.registerLazySingleton<OfflineQueueService>(
() => OfflineQueueService(sl<LocalDatabase>()),
);
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
sl.registerLazySingleton<WebSocketService>(() => WebSocketService(sl<SecureStorage>()));
sl.registerLazySingleton<LocationReporterService>(() => LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
sl.registerLazySingleton<WebSocketService>(
() => WebSocketService(sl<SecureStorage>()));
sl.registerLazySingleton<LocationReporterService>(() =>
LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
sl.registerLazySingleton<IncomingCallPollingService>(
() => IncomingCallPollingService(sl<ApiClient>()),
);
sl.registerLazySingleton<HardwareShortcutListener>(
() => HardwareShortcutListener(sl<ApiClient>()),
);
sl.registerLazySingleton<VoiceCommandHandler>(
() => VoiceCommandHandler(sl<SttService>(), sl<TtsService>()),
);
sl.registerLazySingleton<WalkGuideRepository>(
() => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()),
);
sl.registerFactory<WalkGuideCubit>(
() => WalkGuideCubit(sl<WalkGuideRepository>()));
sl.registerLazySingleton<SosRepository>(
() => SosRepositoryImpl(sl<ApiClient>()));
sl.registerFactory<SosCubit>(() => SosCubit(sl<SosRepository>()));
sl.registerLazySingleton<NotificationRepository>(
() => NotificationRepositoryImpl(sl<ApiClient>(), sl<LocalDatabase>()),
);
sl.registerFactory<NotificationCubit>(
() => NotificationCubit(sl<NotificationRepository>()),
);
final serverUrl = await AppConstants.getServerUrl();
if (serverUrl != null && serverUrl.isNotEmpty) {
await sl<ApiClient>().init(serverUrl);
}
try {
await sl<TtsService>().init();
} catch (e) {
debugPrint('TTS init skipped: $e');
}
await sl<YoloDetector>().init();
if (!kIsWeb) {
try {
await sl<SttService>().init();
} catch (e) {
debugPrint('STT init skipped: $e');
}
}
sl<VoiceCommandHandler>().loadDefaultCommands();
if (!kIsWeb) {
await sl<FcmService>().init();
}
}
@@ -1,60 +1,150 @@
import 'package:go_router/go_router.dart';
import '../app/injection_container.dart';
import '../core/constants/app_constants.dart';
import '../features/screens.dart';
import '../core/storage/secure_storage.dart';
import '../features/activity_log/presentation/screens/activity_log_screen.dart'
as activity;
import '../features/ai_benchmark/presentation/screens/ai_benchmark_screen.dart'
as benchmark;
import '../features/auth/login_screen.dart' as auth_login;
import '../features/auth/register_screen.dart' as auth_register;
import '../features/auth/splash_screen.dart' as auth_splash;
import '../features/call/presentation/screens/call_screen.dart' as call;
import '../features/guardian_dashboard/presentation/screens/guardian_activity_log_screen.dart'
as guardian_logs;
import '../features/guardian_dashboard/presentation/screens/guardian_ai_config_screen.dart'
as guardian_ai;
import '../features/guardian_dashboard/presentation/screens/guardian_map_screen.dart'
as guardian_map;
import '../features/guardian_dashboard/presentation/screens/guardian_send_notification_screen.dart'
as guardian_send;
import '../features/guardian_dashboard/presentation/screens/guardian_settings_screen.dart'
as guardian_settings;
import '../features/guardian_dashboard/presentation/screens/guardian_tools_screen.dart'
as guardian_tools;
import '../features/home/presentation/guardian_dashboard_screen.dart'
as guardian_home;
import '../features/manual/manual_screen.dart' as manual;
import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart'
as nav;
import '../features/notifications/presentation/screens/notification_screen.dart'
as notifications;
import '../features/pairing/presentation/screens/pairing_screens.dart'
as pairing;
import '../features/server_connect/server_connect_server.dart'
as server_connect;
import '../features/settings/presentation/screens/user_settings_screen.dart'
as user_settings;
import '../features/sos/presentation/screens/sos_screen.dart' as sos;
import '../features/walk_guide/presentation/screens/walk_guide_screen.dart'
as walk_guide;
import '../shared/widgets/app_shells.dart';
final GoRouter appRouter = GoRouter(
initialLocation: '/splash',
initialLocation: '/server-connect',
redirect: (context, state) async {
final path = state.matchedLocation;
final serverUrl = await AppConstants.getServerUrl();
final isEditingServer =
path == '/server-connect' && state.uri.queryParameters['edit'] == '1';
final isPublicRoute = path == '/server-connect' ||
path == '/splash' ||
path == '/login' ||
path == '/register';
if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') {
return '/server-connect';
}
if (path == '/server-connect' &&
if (!isEditingServer &&
path == '/server-connect' &&
serverUrl != null &&
serverUrl.isNotEmpty) {
return '/splash';
}
if (serverUrl == null || serverUrl.isEmpty) return null;
final storage = sl<SecureStorage>();
final token = await storage.getAccessToken();
final role = await storage.getUserRole();
if ((token == null || token.isEmpty) && !isPublicRoute) {
return '/login';
}
if (token != null && token.isNotEmpty) {
final home = role == 'ROLE_GUARDIAN'
? '/guardian/dashboard'
: role == 'ROLE_USER'
? '/user/walkguide'
: '/login';
if (path == '/splash' || path == '/login' || path == '/register') {
return home;
}
if (path.startsWith('/guardian') && role != 'ROLE_GUARDIAN') {
return '/user/walkguide';
}
if (path.startsWith('/user') && role != 'ROLE_USER') {
return '/guardian/dashboard';
}
}
return null;
},
routes: [
GoRoute(
path: '/server-connect',
builder: (_, __) => const ServerConnectScreen()),
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
builder: (_, state) => server_connect.ServerConnectScreen(
editMode: state.uri.queryParameters['edit'] == '1',
)),
GoRoute(
path: '/incoming-call', builder: (_, __) => const IncomingCallScreen()),
path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()),
GoRoute(
path: '/register',
builder: (_, __) => const auth_register.RegisterScreen()),
GoRoute(
path: '/incoming-call',
builder: (_, state) {
final extra = state.extra is Map
? Map<String, dynamic>.from(state.extra as Map)
: <String, dynamic>{};
return call.IncomingCallScreen(
callerName: extra['callerName']?.toString() ?? 'Guardian',
callerId: int.tryParse(extra['callerId']?.toString() ?? ''),
channelName: extra['channelName']?.toString(),
agoraToken: extra['agoraToken']?.toString(),
agoraAppId: extra['agoraAppId']?.toString(),
);
}),
ShellRoute(
builder: (_, __, child) => UserShell(child: child),
routes: [
GoRoute(
path: '/user/walkguide',
builder: (_, __) => const WalkGuideScreen()),
GoRoute(path: '/user/sos', builder: (_, __) => const SosScreen()),
builder: (_, __) => const walk_guide.WalkGuideScreen()),
GoRoute(path: '/user/sos', builder: (_, __) => const sos.SosScreen()),
GoRoute(
path: '/user/activity',
builder: (_, __) => const ActivityLogScreen()),
builder: (_, __) => const activity.ActivityLogScreen()),
GoRoute(
path: '/user/notifications',
builder: (_, __) => const NotificationScreen()),
builder: (_, __) => const notifications.NotificationScreen()),
GoRoute(
path: '/user/navigation',
builder: (_, __) => const NavigationModeScreen()),
builder: (_, __) => const nav.NavigationModeScreen()),
GoRoute(
path: '/user/settings',
builder: (_, __) => const UserSettingsScreen()),
builder: (_, __) => const user_settings.UserSettingsScreen()),
GoRoute(
path: '/user/pairing',
builder: (_, __) => const UserPairingScreen()),
GoRoute(path: '/user/call', builder: (_, __) => const CallScreen()),
builder: (_, __) => const pairing.UserPairingScreen()),
GoRoute(
path: '/user/call', builder: (_, __) => const call.CallScreen()),
GoRoute(
path: '/user/benchmark',
builder: (_, __) => const AiBenchmarkScreen()),
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
GoRoute(
path: '/user/manual',
builder: (_, __) => const manual.ManualScreen()),
],
),
ShellRoute(
@@ -62,37 +152,45 @@ final GoRouter appRouter = GoRouter(
routes: [
GoRoute(
path: '/guardian/dashboard',
builder: (_, __) => const GuardianDashboardScreen()),
builder: (_, __) => const guardian_home.GuardianDashboardScreen()),
GoRoute(
path: '/guardian/map',
builder: (_, __) => const GuardianMapScreen()),
builder: (_, __) => const guardian_map.GuardianMapScreen()),
GoRoute(
path: '/guardian/logs',
builder: (_, __) => const GuardianActivityLogScreen()),
builder: (_, __) =>
const guardian_logs.GuardianActivityLogScreen()),
GoRoute(
path: '/guardian/send-notif',
builder: (_, __) => const GuardianSendNotifScreen()),
builder: (_, __) => const guardian_send.GuardianSendNotifScreen()),
GoRoute(
path: '/guardian/ai-config',
builder: (_, __) => const GuardianAiConfigScreen()),
builder: (_, __) => const guardian_ai.GuardianAiConfigScreen()),
GoRoute(
path: '/guardian/voice-cmd',
builder: (_, __) => const GuardianVoiceCmdScreen()),
builder: (_, __) => const guardian_tools.GuardianVoiceCmdScreen()),
GoRoute(
path: '/guardian/shortcuts',
builder: (_, __) => const GuardianShortcutScreen()),
builder: (_, __) => const guardian_tools.GuardianShortcutScreen()),
GoRoute(
path: '/guardian/geofence',
builder: (_, __) => const GuardianGeofenceScreen()),
builder: (_, __) => const guardian_tools.GuardianGeofenceScreen()),
GoRoute(
path: '/guardian/pairing',
builder: (_, __) => const GuardianPairingScreen()),
builder: (_, __) => const pairing.GuardianPairingScreen()),
GoRoute(
path: '/guardian/settings',
builder: (_, __) => const GuardianSettingsScreen()),
builder: (_, __) =>
const guardian_settings.GuardianSettingsScreen()),
GoRoute(
path: '/guardian/call',
builder: (_, __) => const call.CallScreen(
targetLabel: 'User',
returnRoute: '/guardian/dashboard',
)),
GoRoute(
path: '/guardian/benchmark',
builder: (_, __) => const AiBenchmarkScreen()),
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
],
),
],
@@ -0,0 +1,57 @@
import '../services/haptic_service.dart';
import '../services/tts_service.dart';
import 'obstacle_analyzer.dart';
abstract class ObstacleAlertStrategy {
Future<void> alert(DetectionResult detection);
}
class TtsOnlyObstacleAlertStrategy implements ObstacleAlertStrategy {
final TtsService _ttsService;
const TtsOnlyObstacleAlertStrategy(this._ttsService);
@override
Future<void> alert(DetectionResult detection) {
return _ttsService.speakImmediate(detection.spokenId);
}
}
class HapticOnlyObstacleAlertStrategy implements ObstacleAlertStrategy {
final HapticService _hapticService;
const HapticOnlyObstacleAlertStrategy(this._hapticService);
@override
Future<void> alert(DetectionResult detection) {
return _vibrateByDistance(detection);
}
Future<void> _vibrateByDistance(DetectionResult detection) {
final distance = detection.estimatedDistance.toLowerCase();
if (distance.startsWith('very close')) {
return _hapticService.obstacleVeryClose();
}
if (distance.startsWith('close')) {
return _hapticService.obstacleClose();
}
return _hapticService.obstacleMedium();
}
}
class TtsWithHapticObstacleAlertStrategy implements ObstacleAlertStrategy {
final TtsService _ttsService;
final HapticService _hapticService;
const TtsWithHapticObstacleAlertStrategy(
this._ttsService,
this._hapticService,
);
@override
Future<void> alert(DetectionResult detection) async {
final haptic = HapticOnlyObstacleAlertStrategy(_hapticService);
await haptic.alert(detection);
await _ttsService.speakImmediate(detection.spokenId);
}
}
@@ -23,12 +23,14 @@ class DetectionResult {
final double confidence;
final ObstacleDirection direction;
final String estimatedDistance;
final BoundingBox? box;
const DetectionResult({
required this.label,
required this.confidence,
required this.direction,
required this.estimatedDistance,
this.box,
});
String get directionName => direction.name.toUpperCase();
@@ -39,7 +41,7 @@ class DetectionResult {
ObstacleDirection.center => 'tengah',
ObstacleDirection.right => 'kanan',
};
return 'Hati-hati, $label di $area. Jarak $estimatedDistance.';
return 'Hati-hati, ${ObstacleAnalyzer.spokenLabel(label)} di $area. Jarak $estimatedDistance.';
}
}
@@ -68,7 +70,7 @@ class ObstacleAnalyzer {
ObstacleDirection.center => 'depan',
ObstacleDirection.right => 'kanan',
};
return 'Hati-hati, ${result.label} di $directionLabel. '
return 'Hati-hati, ${spokenLabel(result.label)} di $directionLabel. '
'Jarak ${result.estimatedDistance}.';
}
@@ -90,7 +92,12 @@ class ObstacleAnalyzer {
final bi = order.indexOf(b.estimatedDistance);
final aRank = ai == -1 ? order.length : ai;
final bRank = bi == -1 ? order.length : bi;
return aRank.compareTo(bRank);
final distanceCompare = aRank.compareTo(bRank);
if (distanceCompare != 0) return distanceCompare;
final directionCompare =
_directionRisk(a.direction).compareTo(_directionRisk(b.direction));
if (directionCompare != 0) return directionCompare;
return b.confidence.compareTo(a.confidence);
});
return sorted.first;
}
@@ -102,15 +109,43 @@ class ObstacleAnalyzer {
return detections.where((d) => d.confidence >= threshold).toList();
}
DetectionResult analyzeFallback({
String label = 'person',
double confidence = 0.86,
}) {
return DetectionResult(
label: label,
confidence: confidence,
direction: ObstacleDirection.center,
estimatedDistance: 'Close (1-2m)',
);
int _directionRisk(ObstacleDirection direction) {
return switch (direction) {
ObstacleDirection.center => 0,
ObstacleDirection.left => 1,
ObstacleDirection.right => 1,
};
}
static String spokenLabel(String label) {
return switch (label.toLowerCase()) {
'person' => 'orang',
'bicycle' => 'sepeda',
'car' => 'mobil',
'motorcycle' => 'motor',
'airplane' => 'pesawat',
'bus' => 'bus',
'train' => 'kereta',
'truck' => 'truk',
'boat' => 'perahu',
'traffic light' => 'lampu lalu lintas',
'fire hydrant' => 'hidran',
'stop sign' => 'rambu berhenti',
'parking meter' => 'meter parkir',
'bench' => 'bangku',
'chair' => 'kursi',
'couch' => 'sofa',
'potted plant' => 'pot tanaman',
'dining table' => 'meja',
'tv' => 'televisi',
'backpack' => 'tas',
'umbrella' => 'payung',
'handbag' => 'tas tangan',
'suitcase' => 'koper',
'skateboard' => 'papan skate',
'surfboard' => 'papan selancar',
'sports ball' => 'bola',
_ => label,
};
}
}
@@ -1,19 +1,52 @@
import 'dart:math' as math;
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../constants/app_constants.dart';
import 'obstacle_analyzer.dart';
import 'yolo_runtime.dart';
class YoloDetector {
final ObstacleAnalyzer _analyzer;
List<String> _labels = const [];
YoloRuntime? _runtime;
List<int> _inputShape = const [];
List<int> _outputShape = const [];
YoloTensorType? _inputType;
YoloTensorType? _outputType;
bool _ready = false;
String? _lastError;
int _lastDecodedCount = 0;
int _lastConfidenceCount = 0;
int _lastObstacleCount = 0;
int _lastKeptCount = 0;
int _lastInferenceMs = 0;
String? _lastBestLabel;
double? _lastBestConfidence;
YoloDetector(this._analyzer);
bool get isReady => _ready;
bool get isReady => _ready && _runtime != null;
String? get lastError => _lastError;
String get diagnosticsSummary {
if (!isReady) {
return _lastError == null
? 'YOLO runtime belum aktif'
: 'YOLO runtime error: $_lastError';
}
final bestLabel = _lastBestLabel;
final bestConfidence = _lastBestConfidence;
final bestText = bestLabel == null || bestConfidence == null
? 'best: none'
: 'best: $bestLabel ${(bestConfidence * 100).toStringAsFixed(0)}%';
return 'frames ok, decoded $_lastDecodedCount, >=threshold $_lastConfidenceCount, obstacle $_lastObstacleCount, kept $_lastKeptCount, $bestText, ${_lastInferenceMs}ms';
}
Future<void> init() async {
dispose();
_lastError = null;
try {
_labels = (await rootBundle.loadString(AppConstants.yoloLabelsPath))
.split('\n')
@@ -24,24 +57,681 @@ class YoloDetector {
_ready = false;
return;
}
await rootBundle.load(await AppConstants.getSelectedYoloModelPath());
_runtime = await YoloRuntime.load(
await AppConstants.getSelectedYoloModelPath(),
);
_inputShape = _runtime!.inputShape;
_outputShape = _runtime!.outputShape;
_inputType = _runtime!.inputType;
_outputType = _runtime!.outputType;
_ready = true;
} catch (e) {
debugPrint('YOLO fallback mode: $e');
_lastError = e.toString();
debugPrint('YOLO runtime initialization skipped: $e');
_ready = false;
}
}
Future<DetectionResult?> detectFallback() async {
if (_ready) {
// Full tensor pre/post-processing belongs here once yolov8n.tflite is present.
// The app keeps a deterministic fallback so demo flows remain testable.
Future<DetectionResult?> detect(
CameraImage image, {
double confidenceThreshold = 0.25,
}) async {
if (!isReady) return null;
try {
final stopwatch = Stopwatch()..start();
final input = _buildCameraInput(image);
final output = Uint8List(_runtime!.outputByteLength);
_runtime!.run(input, output);
final detections = _decodeDetections(output);
_recordDecoded(detections);
final filtered =
_analyzer.filterByConfidence(detections, confidenceThreshold);
final kept = _nonMaxSuppression(filtered);
_lastInferenceMs = stopwatch.elapsedMilliseconds;
_lastConfidenceCount = filtered.length;
_lastObstacleCount =
filtered.where((d) => _isObstacleLabel(d.label)).length;
_lastKeptCount = kept.length;
return _analyzer.prioritize(kept);
} catch (e) {
_lastError = e.toString();
debugPrint('YOLO inference skipped: $e');
return null;
}
}
Future<DetectionResult?> detectSynthetic({
double confidenceThreshold = 0.25,
}) async {
if (!isReady) return null;
try {
final input = _buildSyntheticInput();
final output = Uint8List(_runtime!.outputByteLength);
_runtime!.run(input, output);
final detections = _decodeDetections(output);
_recordDecoded(detections);
final filtered =
_analyzer.filterByConfidence(detections, confidenceThreshold);
final kept = _nonMaxSuppression(filtered);
_lastConfidenceCount = filtered.length;
_lastObstacleCount =
filtered.where((d) => _isObstacleLabel(d.label)).length;
_lastKeptCount = kept.length;
return _analyzer.prioritize(kept);
} catch (e) {
_lastError = e.toString();
debugPrint('YOLO synthetic skipped: $e');
return null;
}
final label = _labels.isNotEmpty ? _labels.first : 'person';
return _analyzer.analyzeFallback(label: label);
}
void dispose() {
_runtime?.close();
_runtime = null;
_inputShape = const [];
_outputShape = const [];
_inputType = null;
_outputType = null;
_ready = false;
_lastDecodedCount = 0;
_lastConfidenceCount = 0;
_lastObstacleCount = 0;
_lastKeptCount = 0;
_lastInferenceMs = 0;
_lastBestLabel = null;
_lastBestConfidence = null;
}
void _recordDecoded(List<DetectionResult> detections) {
_lastDecodedCount = detections.length;
_lastBestLabel = null;
_lastBestConfidence = null;
for (final detection in detections) {
if (_lastBestConfidence == null ||
detection.confidence > _lastBestConfidence!) {
_lastBestLabel = detection.label;
_lastBestConfidence = detection.confidence;
}
}
}
Uint8List _buildCameraInput(CameraImage image) {
final inputInfo = _inputInfo();
final bytes = Uint8List(inputInfo.byteLength);
final data = ByteData.view(bytes.buffer);
for (var y = 0; y < inputInfo.height; y++) {
final srcY = (y * image.height / inputInfo.height).floor();
for (var x = 0; x < inputInfo.width; x++) {
final srcX = (x * image.width / inputInfo.width).floor();
final rgb = _rgbAt(image, srcX, srcY);
_writeInputValue(data, bytes, inputInfo, x, y, 0, (rgb >> 16) & 0xFF);
_writeInputValue(data, bytes, inputInfo, x, y, 1, (rgb >> 8) & 0xFF);
_writeInputValue(data, bytes, inputInfo, x, y, 2, rgb & 0xFF);
}
}
return bytes;
}
Uint8List _buildSyntheticInput() {
final inputInfo = _inputInfo();
final bytes = Uint8List(inputInfo.byteLength);
final data = ByteData.view(bytes.buffer);
for (var y = 0; y < inputInfo.height; y++) {
for (var x = 0; x < inputInfo.width; x++) {
final shade = ((x / inputInfo.width) * 64 + 96).round();
_writeInputValue(data, bytes, inputInfo, x, y, 0, shade);
_writeInputValue(data, bytes, inputInfo, x, y, 1, shade);
_writeInputValue(data, bytes, inputInfo, x, y, 2, shade);
}
}
return bytes;
}
_InputInfo _inputInfo() {
if (_inputShape.length != 4) {
throw StateError('Unsupported YOLO input shape: $_inputShape');
}
final type = _inputType;
if (type == null) throw StateError('YOLO input type is unavailable');
final channelsLast = _inputShape.last == 3;
final channelsFirst = _inputShape[1] == 3;
if (!channelsLast && !channelsFirst) {
throw StateError('Unsupported YOLO channel layout: $_inputShape');
}
if (type != YoloTensorType.float32 &&
type != YoloTensorType.uint8 &&
type != YoloTensorType.int8) {
throw StateError('Unsupported YOLO input type: $type');
}
final height = channelsLast ? _inputShape[1] : _inputShape[2];
final width = channelsLast ? _inputShape[2] : _inputShape[3];
final bytesPerValue = type == YoloTensorType.float32 ? 4 : 1;
return _InputInfo(
width: width,
height: height,
channelsLast: channelsLast,
type: type,
bytesPerValue: bytesPerValue,
);
}
void _writeInputValue(
ByteData data,
Uint8List bytes,
_InputInfo info,
int x,
int y,
int channel,
int value,
) {
final elementIndex = info.channelsLast
? ((y * info.width + x) * 3 + channel)
: (channel * info.width * info.height + y * info.width + x);
final byteOffset = elementIndex * info.bytesPerValue;
if (info.type == YoloTensorType.float32) {
data.setFloat32(byteOffset, value / 255.0, Endian.little);
} else if (info.type == YoloTensorType.uint8) {
bytes[byteOffset] = value;
} else if (info.type == YoloTensorType.int8) {
data.setInt8(byteOffset, value - 128);
}
}
int _rgbAt(CameraImage image, int x, int y) {
final group = image.format.group;
if (group == ImageFormatGroup.bgra8888) {
final plane = image.planes.first;
final index = y * plane.bytesPerRow + x * 4;
if (index + 2 >= plane.bytes.length) return 0;
final b = plane.bytes[index];
final g = plane.bytes[index + 1];
final r = plane.bytes[index + 2];
return (r << 16) | (g << 8) | b;
}
if (image.planes.length < 3) {
final plane = image.planes.first;
final index = math.min(y * plane.bytesPerRow + x, plane.bytes.length - 1);
final luma = plane.bytes[index];
return (luma << 16) | (luma << 8) | luma;
}
final yPlane = image.planes[0];
final uPlane = image.planes[1];
final vPlane = image.planes[2];
final yIndex =
math.min(y * yPlane.bytesPerRow + x, yPlane.bytes.length - 1);
final uvX = x ~/ 2;
final uvY = y ~/ 2;
final uvPixelStride = uPlane.bytesPerPixel ?? 1;
final uvIndex = math.min(
uvY * uPlane.bytesPerRow + uvX * uvPixelStride,
math.min(uPlane.bytes.length, vPlane.bytes.length) - 1,
);
final yy = yPlane.bytes[yIndex].toDouble();
final uu = uPlane.bytes[uvIndex].toDouble() - 128.0;
final vv = vPlane.bytes[uvIndex].toDouble() - 128.0;
final r = _clampByte(yy + 1.402 * vv);
final g = _clampByte(yy - 0.344136 * uu - 0.714136 * vv);
final b = _clampByte(yy + 1.772 * uu);
return (r << 16) | (g << 8) | b;
}
int _clampByte(double value) => value.round().clamp(0, 255);
List<DetectionResult> _decodeDetections(Uint8List outputBuffer) {
final values = _readOutput(outputBuffer);
if (values.isEmpty || _outputShape.isEmpty) return const [];
final dims = _outputShape.where((d) => d > 0).toList();
final effectiveShape =
dims.isNotEmpty && dims.first == 1 ? dims.sublist(1) : dims;
if (effectiveShape.length == 2) {
final a = effectiveShape[0];
final b = effectiveShape[1];
if (a >= 5 && b > a) {
return _decodeFeatureFirst(values, a, b);
}
if (b >= 5) {
return _decodeRowFirst(values, a, b);
}
}
if (effectiveShape.length == 3 && effectiveShape[0] == 1) {
final a = effectiveShape[1];
final b = effectiveShape[2];
if (a >= 5 && b > a) {
return _decodeFeatureFirst(values, a, b);
}
if (b >= 5) {
return _decodeRowFirst(values, a, b);
}
}
return const [];
}
List<double> _readOutput(Uint8List bytes) {
final type = _outputType;
if (type == null) return const [];
final data = ByteData.view(bytes.buffer);
if (type == YoloTensorType.float32) {
return [
for (var i = 0; i < bytes.length; i += 4)
data.getFloat32(i, Endian.little),
];
}
if (type == YoloTensorType.uint8) {
return [for (final byte in bytes) byte / 255.0];
}
if (type == YoloTensorType.int8) {
return [for (var i = 0; i < bytes.length; i++) data.getInt8(i) / 127.0];
}
if (type == YoloTensorType.float16) {
// Most exported YOLOv8 TFLite files use float32 output. Float16 is rare;
// Keep preprocessing explicit if an unsupported variant is selected.
throw StateError('Float16 YOLO output is not supported yet');
}
throw StateError('Unsupported YOLO output type: $type');
}
List<DetectionResult> _decodeFeatureFirst(
List<double> values,
int featureCount,
int boxCount,
) {
final detections = <DetectionResult>[];
final classStart = _classStart(featureCount);
for (var i = 0; i < boxCount; i++) {
final cx = values[i];
final cy = values[boxCount + i];
final w = values[2 * boxCount + i];
final h = values[3 * boxCount + i];
final scored = _bestClassFeatureFirst(values, featureCount, boxCount,
classStart: classStart, boxIndex: i);
if (scored == null) continue;
detections.add(_fromCenterBox(
label: scored.label,
confidence: scored.score,
cx: cx,
cy: cy,
width: w,
height: h,
));
}
return detections;
}
List<DetectionResult> _decodeRowFirst(
List<double> values,
int rowCount,
int featureCount,
) {
final detections = <DetectionResult>[];
if (featureCount == 6 || featureCount == 7) {
for (var row = 0; row < rowCount; row++) {
final offset = row * featureCount;
final score = values[offset + 4].clamp(0.0, 1.0);
final classIndex = values[offset + 5].round();
detections.add(_fromCornerBox(
label: _labelFor(classIndex),
confidence: score,
left: values[offset],
top: values[offset + 1],
right: values[offset + 2],
bottom: values[offset + 3],
));
}
return detections;
}
final classStart = _classStart(featureCount);
for (var row = 0; row < rowCount; row++) {
final offset = row * featureCount;
final scored =
_bestClassRow(values, offset, featureCount, classStart: classStart);
if (scored == null) continue;
detections.add(_fromCenterBox(
label: scored.label,
confidence: scored.score,
cx: values[offset],
cy: values[offset + 1],
width: values[offset + 2],
height: values[offset + 3],
));
}
return detections;
}
int _classStart(int featureCount) {
if (featureCount == 85) return 5; // YOLOv5 COCO: xywh + obj + 80 cls
if (_labels.isNotEmpty && featureCount == _labels.length + 5) return 5;
return 4;
}
_ScoredLabel? _bestClassFeatureFirst(
List<double> values,
int featureCount,
int boxCount, {
required int classStart,
required int boxIndex,
}) {
final objectness = classStart == 5 ? values[4 * boxCount + boxIndex] : 1.0;
var bestScore = 0.0;
var bestClass = 0;
for (var feature = classStart; feature < featureCount; feature++) {
final score = objectness * values[feature * boxCount + boxIndex];
if (score > bestScore) {
bestScore = score;
bestClass = feature - classStart;
}
}
if (bestScore <= 0) return null;
return _ScoredLabel(
_labelFor(bestClass, classCount: featureCount - classStart),
bestScore.clamp(0.0, 1.0),
);
}
_ScoredLabel? _bestClassRow(
List<double> values,
int offset,
int featureCount, {
required int classStart,
}) {
final objectness = classStart == 5 ? values[offset + 4] : 1.0;
var bestScore = 0.0;
var bestClass = 0;
for (var feature = classStart; feature < featureCount; feature++) {
final score = objectness * values[offset + feature];
if (score > bestScore) {
bestScore = score;
bestClass = feature - classStart;
}
}
if (bestScore <= 0) return null;
return _ScoredLabel(
_labelFor(bestClass, classCount: featureCount - classStart),
bestScore.clamp(0.0, 1.0),
);
}
String _labelFor(int classIndex, {int? classCount}) {
if (classCount != null && classCount > _labels.length) {
return _cocoObstacleLabels[classIndex] ?? 'object';
}
if (classIndex >= 0 && classIndex < _labels.length) {
return _labels[classIndex];
}
return 'object';
}
DetectionResult _fromCenterBox({
required String label,
required double confidence,
required double cx,
required double cy,
required double width,
required double height,
}) {
return _fromCornerBox(
label: label,
confidence: confidence,
left: cx - width / 2,
top: cy - height / 2,
right: cx + width / 2,
bottom: cy + height / 2,
);
}
DetectionResult _fromCornerBox({
required String label,
required double confidence,
required double left,
required double top,
required double right,
required double bottom,
}) {
final inputInfo = _inputInfo();
final normalized =
[left, top, right, bottom].every((value) => value >= 0 && value <= 1.5);
final scaledLeft = normalized
? left * ObstacleAnalyzer.frameWidth
: left / inputInfo.width * ObstacleAnalyzer.frameWidth;
final scaledRight = normalized
? right * ObstacleAnalyzer.frameWidth
: right / inputInfo.width * ObstacleAnalyzer.frameWidth;
final scaledTop = normalized
? top * ObstacleAnalyzer.frameHeight
: top / inputInfo.height * ObstacleAnalyzer.frameHeight;
final scaledBottom = normalized
? bottom * ObstacleAnalyzer.frameHeight
: bottom / inputInfo.height * ObstacleAnalyzer.frameHeight;
final box = BoundingBox(
left: scaledLeft.clamp(0.0, ObstacleAnalyzer.frameWidth),
top: scaledTop.clamp(0.0, ObstacleAnalyzer.frameHeight),
right: scaledRight.clamp(0.0, ObstacleAnalyzer.frameWidth),
bottom: scaledBottom.clamp(0.0, ObstacleAnalyzer.frameHeight),
);
return DetectionResult(
label: label,
confidence: confidence,
direction: _analyzer.analyzeDirection(box),
estimatedDistance: _analyzer.estimateDistance(box),
box: box,
);
}
List<DetectionResult> _nonMaxSuppression(
List<DetectionResult> detections, {
double iouThreshold = 0.45,
int maxDetections = 8,
}) {
final candidates = detections
.where((d) => d.box != null && _isObstacleLabel(d.label))
.toList()
..sort((a, b) => b.confidence.compareTo(a.confidence));
final selected = <DetectionResult>[];
for (final detection in candidates) {
final box = detection.box;
if (box == null) continue;
final overlaps = selected.any((kept) =>
kept.label == detection.label &&
kept.box != null &&
_iou(box, kept.box!) > iouThreshold);
if (overlaps) continue;
selected.add(detection);
if (selected.length >= maxDetections) break;
}
return selected;
}
bool _isObstacleLabel(String label) {
return _walkGuideObstacleLabels.contains(label.toLowerCase());
}
double _iou(BoundingBox a, BoundingBox b) {
final left = math.max(a.left, b.left);
final top = math.max(a.top, b.top);
final right = math.min(a.right, b.right);
final bottom = math.min(a.bottom, b.bottom);
final width = math.max(0.0, right - left);
final height = math.max(0.0, bottom - top);
final intersection = width * height;
final union = a.width * a.height + b.width * b.height - intersection;
if (union <= 0) return 0;
return intersection / union;
}
}
const Set<String> _walkGuideObstacleLabels = {
'person',
'bicycle',
'car',
'motorcycle',
'truck',
'bus',
'train',
'boat',
'traffic light',
'fire hydrant',
'stop sign',
'parking meter',
'bench',
'stairs',
'stair',
'pothole',
'curb',
'pole',
'bollard',
'cone',
'road cone',
'barrier',
'fence',
'door',
'trash can',
'signboard',
'crosswalk',
'sidewalk',
'wall',
'backpack',
'umbrella',
'handbag',
'suitcase',
'chair',
'couch',
'potted plant',
'dining table',
'tv',
'laptop',
'cell phone',
'bottle',
'cup',
'book',
'object',
};
const Map<int, String> _cocoObstacleLabels = {
0: 'person',
1: 'bicycle',
2: 'car',
3: 'motorcycle',
4: 'airplane',
5: 'bus',
6: 'train',
7: 'truck',
8: 'boat',
9: 'traffic light',
10: 'fire hydrant',
11: 'stop sign',
12: 'parking meter',
13: 'bench',
14: 'bird',
15: 'cat',
16: 'dog',
17: 'horse',
18: 'sheep',
19: 'cow',
20: 'elephant',
21: 'bear',
22: 'zebra',
23: 'giraffe',
24: 'backpack',
25: 'umbrella',
26: 'handbag',
27: 'tie',
28: 'suitcase',
29: 'frisbee',
30: 'skis',
31: 'snowboard',
32: 'sports ball',
33: 'kite',
34: 'baseball bat',
35: 'baseball glove',
36: 'skateboard',
37: 'surfboard',
38: 'tennis racket',
39: 'bottle',
40: 'wine glass',
41: 'cup',
42: 'fork',
43: 'knife',
44: 'spoon',
45: 'bowl',
46: 'banana',
47: 'apple',
48: 'sandwich',
49: 'orange',
50: 'broccoli',
51: 'carrot',
52: 'hot dog',
53: 'pizza',
54: 'donut',
55: 'cake',
56: 'chair',
57: 'couch',
58: 'potted plant',
59: 'bed',
60: 'dining table',
61: 'toilet',
62: 'tv',
63: 'laptop',
64: 'mouse',
65: 'remote',
66: 'keyboard',
67: 'cell phone',
68: 'microwave',
69: 'oven',
70: 'toaster',
71: 'sink',
72: 'refrigerator',
73: 'book',
74: 'clock',
75: 'vase',
76: 'scissors',
77: 'teddy bear',
78: 'hair drier',
79: 'toothbrush',
};
class _InputInfo {
final int width;
final int height;
final bool channelsLast;
final YoloTensorType type;
final int bytesPerValue;
const _InputInfo({
required this.width,
required this.height,
required this.channelsLast,
required this.type,
required this.bytesPerValue,
});
int get byteLength => width * height * 3 * bytesPerValue;
}
class _ScoredLabel {
final String label;
final double score;
const _ScoredLabel(this.label, this.score);
}
@@ -0,0 +1 @@
export 'yolo_runtime_stub.dart' if (dart.library.io) 'yolo_runtime_native.dart';
@@ -0,0 +1,57 @@
import 'dart:typed_data';
import 'package:tflite_flutter/tflite_flutter.dart';
enum YoloTensorType { float32, uint8, int8, float16, unsupported }
class YoloRuntime {
final Interpreter _interpreter;
final List<int> inputShape;
final List<int> outputShape;
final YoloTensorType inputType;
final YoloTensorType outputType;
final int outputByteLength;
YoloRuntime._({
required Interpreter interpreter,
required this.inputShape,
required this.outputShape,
required this.inputType,
required this.outputType,
required this.outputByteLength,
}) : _interpreter = interpreter;
static Future<YoloRuntime> load(String assetPath) async {
final options = InterpreterOptions()..threads = 2;
final interpreter =
await Interpreter.fromAsset(assetPath, options: options);
final input = interpreter.getInputTensor(0);
final output = interpreter.getOutputTensor(0);
return YoloRuntime._(
interpreter: interpreter,
inputShape: input.shape,
outputShape: output.shape,
inputType: _mapTensorType(input.type),
outputType: _mapTensorType(output.type),
outputByteLength: output.numBytes(),
);
}
void run(Uint8List input, Uint8List output) {
_interpreter.run(input, output);
}
void close() {
_interpreter.close();
}
}
YoloTensorType _mapTensorType(TensorType type) {
return switch (type) {
TensorType.float32 => YoloTensorType.float32,
TensorType.uint8 => YoloTensorType.uint8,
TensorType.int8 => YoloTensorType.int8,
TensorType.float16 => YoloTensorType.float16,
_ => YoloTensorType.unsupported,
};
}
@@ -0,0 +1,23 @@
import 'dart:typed_data';
enum YoloTensorType { float32, uint8, int8, float16, unsupported }
class YoloRuntime {
const YoloRuntime();
static Future<YoloRuntime> load(String assetPath) async {
throw UnsupportedError('YOLO TFLite runtime is only available on mobile.');
}
List<int> get inputShape => const [];
List<int> get outputShape => const [];
YoloTensorType get inputType => YoloTensorType.unsupported;
YoloTensorType get outputType => YoloTensorType.unsupported;
int get outputByteLength => 0;
void run(Uint8List input, Uint8List output) {
throw UnsupportedError('YOLO TFLite runtime is only available on mobile.');
}
void close() {}
}
@@ -1,28 +1,30 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; // Wajib import ini
import 'constants/app_constants.dart';
@Deprecated('Use ApiClient for authenticated requests. Kept for legacy callers.')
class ApiService {
static String get baseUrl {
if (kIsWeb) {
// Jika di Chrome/Web, tembak localhost langsung
return 'http://localhost:8080/api';
} else {
// Jika di Emulator Android, tembak IP khusus 10.0.2.2
return 'http://10.0.2.2:8080/api';
}
}
ApiService._(String baseUrl)
: _dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 5),
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
));
final Dio _dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 5),
// Penting buat Web agar tidak kena error CORS di sisi Client
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
));
final Dio _dio;
static Future<ApiService> create() async {
final serverUrl = await AppConstants.getServerUrl();
if (serverUrl == null || serverUrl.isEmpty) {
throw StateError('WalkGuide server URL has not been configured.');
}
return ApiService._(AppConstants.buildApiUrl(serverUrl));
}
Future<Response> post(String path, Map<String, dynamic> data) async {
return await _dio.post(path, data: data);
}
}
}
@@ -1,13 +1,19 @@
import 'package:shared_preferences/shared_preferences.dart';
class AppConstants {
static const String defaultServerUrl = 'http://127.0.0.1:8080';
static const String publicServerUrl = 'http://202.46.28.170:8080';
static const String _serverUrlKey = 'server_base_url';
static const String _selectedYoloModelKey = 'selected_yolo_model';
// Ambil base URL dari SharedPreferences
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_serverUrlKey);
final saved = prefs.getString(_serverUrlKey);
if (saved == null || saved.trim().isEmpty) {
return null;
}
return saved;
}
// Simpan URL setelah berhasil connect
@@ -22,6 +28,9 @@ class AppConstants {
if (!cleaned.startsWith('http://') && !cleaned.startsWith('https://')) {
cleaned = 'http://$cleaned';
}
cleaned = cleaned
.replaceFirst('://localhost', '://127.0.0.1')
.replaceFirst('://0.0.0.0', '://127.0.0.1');
while (cleaned.endsWith('/')) {
cleaned = cleaned.substring(0, cleaned.length - 1);
}
@@ -61,7 +70,6 @@ class AppConstants {
await prefs.setString(_selectedYoloModelKey, path);
}
// Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=...
static const String agoraAppId =
String.fromEnvironment('AGORA_APP_ID', defaultValue: '');
// Set at build time with: --dart-define=AGORA_APP_ID=your_app_id
static const String agoraAppId = String.fromEnvironment('AGORA_APP_ID');
}
@@ -0,0 +1,119 @@
import 'package:dio/dio.dart';
String friendlyErrorMessage(
Object error, {
required String fallback,
String? connectionHint,
}) {
if (error is DioException) {
return friendlyDioMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
);
}
return fallback;
}
String friendlyDioMessage(
DioException error, {
required String fallback,
String? connectionHint,
}) {
final backendMessage = _cleanBackendMessage(error.response?.data);
if (backendMessage != null) return backendMessage;
final status = error.response?.statusCode;
if (status == 401) return 'Sesi sudah habis atau data login salah.';
if (status == 403) return 'Akun kamu belum punya akses ke fitur ini.';
if (status == 404) return 'Data belum ditemukan.';
if (status == 409) return 'Data sudah dipakai. Coba gunakan data lain.';
if (status != null && status >= 500) {
return 'Server sedang bermasalah. Coba lagi sebentar.';
}
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Coba lagi.';
}
if (error.type == DioExceptionType.connectionError) {
return connectionHint ?? 'Tidak bisa terhubung ke server.';
}
return fallback;
}
String? _cleanBackendMessage(Object? data) {
Object? raw;
if (data is Map) {
raw = data['message'] ?? data['error'] ?? data['errorCode'];
} else if (data is String) {
raw = data;
}
final message = raw?.toString().trim();
if (message == null || message.isEmpty || _looksTechnical(message)) {
return null;
}
return message;
}
bool _looksTechnical(String message) {
final lower = message.toLowerCase();
const blocked = [
'exception',
'dioexception',
'typeerror',
'stacktrace',
'instance of',
'_jsonmap',
'socketexception',
'package:',
'null check operator',
'nosuchmethod',
'formatexception',
'could not execute statement',
'duplicate key',
'constraint',
'sql [',
'illegal base64',
'base64 character',
];
return blocked.any(lower.contains);
}
Future<T?> runFriendly<T>(
Future<T?> Function() action, {
required void Function(String message) onError,
required String fallback,
String? connectionHint,
}) async {
try {
return await action();
} catch (error) {
onError(friendlyErrorMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
));
return null;
}
}
Future<bool> runFriendlyAction(
Future<void> Function() action, {
required void Function(String message) onError,
required String fallback,
String? connectionHint,
}) async {
try {
await action();
return true;
} catch (error) {
onError(friendlyErrorMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
));
return false;
}
}
@@ -0,0 +1,59 @@
import 'package:flutter/widgets.dart';
class AppStrings {
final String localeCode;
const AppStrings(this.localeCode);
static const supportedLocales = [
Locale('id', 'ID'),
Locale('en', 'US'),
];
static AppStrings of(BuildContext context) {
return Localizations.of<AppStrings>(context, AppStrings) ??
const AppStrings('id-ID');
}
String get walkGuideStarted => _pick(
id: 'WalkGuide dimulai',
en: 'WalkGuide started',
);
String get walkGuideStopped => _pick(
id: 'WalkGuide dihentikan',
en: 'WalkGuide stopped',
);
String get sosSent => _pick(
id: 'SOS terkirim. Guardian sudah diberi tahu.',
en: 'SOS sent. Your guardian has been alerted.',
);
String get notificationsOpened => _pick(
id: 'Notifikasi dibuka',
en: 'Notifications opened',
);
String _pick({required String id, required String en}) {
return localeCode == 'en-US' ? en : id;
}
}
class AppStringsDelegate extends LocalizationsDelegate<AppStrings> {
const AppStringsDelegate();
@override
bool isSupported(Locale locale) {
return locale.languageCode == 'id' || locale.languageCode == 'en';
}
@override
Future<AppStrings> load(Locale locale) async {
final code = locale.languageCode == 'en' ? 'en-US' : 'id-ID';
return AppStrings(code);
}
@override
bool shouldReload(covariant LocalizationsDelegate<AppStrings> old) => false;
}
@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../constants/app_constants.dart';
import '../storage/secure_storage.dart';
@@ -24,8 +25,15 @@ class ApiClient {
_dio.interceptors.addAll([
_AuthInterceptor(_secureStorage, _dio),
_ErrorInterceptor(),
LogInterceptor(requestBody: true, responseBody: true),
]);
if (kDebugMode) {
_dio.interceptors.add(LogInterceptor(
requestBody: false,
responseBody: false,
requestHeader: false,
responseHeader: false,
));
}
}
Dio get dio => _dio;
@@ -42,7 +50,8 @@ class _AuthInterceptor extends Interceptor {
_AuthInterceptor(this._storage, this._dio);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
void onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _storage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
@@ -52,7 +61,11 @@ class _AuthInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401 && !_refreshing) {
final status = err.response?.statusCode;
final canRefresh = (status == 401 || status == 403) &&
!_refreshing &&
!err.requestOptions.path.startsWith('/auth/');
if (canRefresh) {
_refreshing = true;
try {
final refresh = await _storage.getRefreshToken();
@@ -78,14 +91,20 @@ class _AuthInterceptor extends Interceptor {
// Retry original request
err.requestOptions.headers['Authorization'] =
'Bearer ${data['accessToken']}';
final retryRes = await _dio.fetch(err.requestOptions);
_refreshing = false;
handler.resolve(retryRes);
try {
final retryRes = await _dio.fetch(err.requestOptions);
_refreshing = false;
handler.resolve(retryRes);
} on DioException catch (retryErr) {
_refreshing = false;
handler.next(retryErr);
}
return;
}
} catch (_) {}
} catch (_) {
await _storage.clearAll();
}
_refreshing = false;
await _storage.clearAll();
}
handler.next(err);
}
@@ -1,5 +1,8 @@
import 'dart:async';
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
import '../constants/app_constants.dart';
import '../network/api_client.dart';
@@ -7,9 +10,19 @@ import '../network/api_client.dart';
class CallService {
final ApiClient _apiClient;
RtcEngine? _engine;
VoidCallback? _onRemoteUserJoined;
VoidCallback? _onRemoteUserOffline;
CallService(this._apiClient);
void setRemoteUserJoinedCallback(VoidCallback? callback) {
_onRemoteUserJoined = callback;
}
void setRemoteUserOfflineCallback(VoidCallback? callback) {
_onRemoteUserOffline = callback;
}
Future<Map<String, dynamic>?> requestToken({required int receiverId}) async {
final res = await _apiClient.dio.post(
'/shared/call/token',
@@ -31,72 +44,219 @@ class CallService {
required int receiverId,
required String channelName,
String? agoraToken,
String? agoraAppId,
int receiverUid = 0,
}) async {
await _apiClient.dio.post('/shared/call/notify', data: {
'receiverId': receiverId,
'channelName': channelName,
'agoraToken': agoraToken,
'receiverUid': receiverUid,
});
await _apiClient.dio.post(
'/shared/call/notify',
data: {
'receiverId': receiverId,
'channelName': channelName,
'agoraToken': agoraToken,
'agoraAppId': agoraAppId,
'receiverUid': receiverUid,
},
);
}
Future<bool> callPairedUser({int uid = 0}) async {
Future<Map<String, dynamic>?> startPairedCall({int uid = 0}) async {
final receiverId = await getPairedReceiverId();
if (receiverId == null) return false;
if (receiverId == null) return null;
final tokenData = await requestToken(receiverId: receiverId);
final channelName = tokenData?['channelName']?.toString();
final token = tokenData?['token']?.toString();
if (channelName == null || channelName.isEmpty) return false;
final appId = tokenData?['appId']?.toString();
final localUid = (tokenData?['uid'] as num?)?.toInt() ?? uid;
if (channelName == null || channelName.isEmpty) return null;
final joined = await joinChannel(
channelName: channelName,
token: token,
uid: uid,
appId: appId,
uid: localUid,
);
if (!joined) return null;
await notifyIncomingCall(
receiverId: receiverId,
channelName: channelName,
agoraToken: token,
agoraAppId: appId,
receiverUid: 0,
);
return {
'receiverId': receiverId,
'channelName': channelName,
'token': token,
'uid': localUid,
};
}
Future<bool> callPairedUser({int uid = 0}) async {
return await startPairedCall(uid: uid) != null;
}
Future<void> acceptIncomingCall({
required int callerId,
required String channelName,
}) async {
await _apiClient.dio.post(
'/shared/call/accept',
data: {'callerId': callerId.toString(), 'channelName': channelName},
);
}
Future<Map<String, dynamic>?> getAcceptedCall() async {
final res = await _apiClient.dio.get('/shared/call/accepted');
final data = res.data['data'];
return data is Map ? Map<String, dynamic>.from(data) : null;
}
Future<Map<String, dynamic>?> getCallState(String? channelName) async {
if (channelName == null || channelName.isEmpty) return null;
final res = await _apiClient.dio.get(
'/shared/call/state',
queryParameters: {'channelName': channelName},
);
final data = res.data['data'];
return data is Map ? Map<String, dynamic>.from(data) : null;
}
Future<void> clearAcceptedCall() async {
await _apiClient.dio.delete('/shared/call/accepted');
}
Future<void> clearPendingCall() async {
await _apiClient.dio.delete('/shared/call/pending');
}
Future<void> endCall(int? otherId, {String? channelName}) async {
if (otherId == null) return;
await _apiClient.dio.post(
'/shared/call/end',
data: {
'otherId': otherId.toString(),
if (channelName != null && channelName.isNotEmpty)
'channelName': channelName,
},
);
if (joined) {
await notifyIncomingCall(
receiverId: receiverId,
channelName: channelName,
agoraToken: token,
receiverUid: uid,
);
}
return joined;
}
Future<bool> joinChannel({
required String channelName,
String? token,
String? appId,
int uid = 0,
}) async {
final joinCompleter = Completer<bool>();
try {
if (AppConstants.agoraAppId.isEmpty) {
final resolvedAppId =
(appId != null && appId.isNotEmpty) ? appId : AppConstants.agoraAppId;
if (resolvedAppId.isEmpty) {
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
return false;
}
if (!await _ensureMicrophonePermission()) {
debugPrint('Agora join skipped: microphone permission denied');
return false;
}
_engine ??= createAgoraRtcEngine();
await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId));
await _engine!.initialize(RtcEngineContext(appId: resolvedAppId));
_engine!.registerEventHandler(
RtcEngineEventHandler(
onJoinChannelSuccess: (_, __) {
if (!joinCompleter.isCompleted) joinCompleter.complete(true);
},
onUserJoined: (_, remoteUid, __) {
debugPrint('Agora remote user joined: $remoteUid');
_onRemoteUserJoined?.call();
},
onUserOffline: (_, remoteUid, reason) {
debugPrint('Agora remote user offline: $remoteUid $reason');
_onRemoteUserOffline?.call();
},
onRemoteAudioStateChanged: (_, remoteUid, state, reason, __) {
debugPrint(
'Agora remote audio state: uid=$remoteUid state=$state reason=$reason',
);
},
onError: (type, msg) {
debugPrint('Agora error: $type $msg');
if (!joinCompleter.isCompleted) joinCompleter.complete(false);
},
),
);
await _engine!.setChannelProfile(
ChannelProfileType.channelProfileCommunication,
);
await _engine!.setClientRole(role: ClientRoleType.clientRoleBroadcaster);
await _engine!.setAudioProfile(
profile: AudioProfileType.audioProfileDefault,
scenario: AudioScenarioType.audioScenarioDefault,
);
await _engine!.enableAudio();
await _engine!.enableLocalAudio(true);
await _engine!.muteAllRemoteAudioStreams(false);
await _engine!.muteLocalAudioStream(false);
await _engine!.enableAudioVolumeIndication(
interval: 500,
smooth: 3,
reportVad: true,
);
await _engine!.adjustRecordingSignalVolume(100);
await _engine!.adjustPlaybackSignalVolume(100);
await _engine!.setDefaultAudioRouteToSpeakerphone(true);
await _engine!.setEnableSpeakerphone(true);
await _engine!.joinChannel(
token: token ?? '',
channelId: channelName,
uid: uid,
options: const ChannelMediaOptions(),
options: const ChannelMediaOptions(
channelProfile: ChannelProfileType.channelProfileCommunication,
clientRoleType: ClientRoleType.clientRoleBroadcaster,
publishMicrophoneTrack: true,
autoSubscribeAudio: true,
),
);
return joinCompleter.future.timeout(
const Duration(seconds: 10),
onTimeout: () {
debugPrint('Agora join timeout for channel $channelName');
return false;
},
);
return true;
} catch (e) {
debugPrint('Agora join skipped: $e');
return false;
}
}
Future<bool> _ensureMicrophonePermission() async {
if (kIsWeb) return true;
final status = await Permission.microphone.request();
return status.isGranted || status.isLimited;
}
Future<void> setMuted(bool muted) async {
await _engine?.muteLocalAudioStream(muted);
}
Future<void> setSpeakerEnabled(bool enabled) async {
await _engine?.setEnableSpeakerphone(enabled);
}
Future<void> leave() async {
_onRemoteUserJoined = null;
_onRemoteUserOffline = null;
await _engine?.leaveChannel();
}
Future<void> dispose() async {
_onRemoteUserJoined = null;
_onRemoteUserOffline = null;
await _engine?.release();
_engine = null;
}

Some files were not shown because too many files have changed in this diff Show More