Compare commits

...

6 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
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
3cb32a4d69 Update UI + Agora Call 2026-05-27 19:36:01 +07:00
202 changed files with 12253 additions and 33650 deletions

5
.gitignore vendored
View File

@ -40,8 +40,13 @@ 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

790
README.md
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 V1V16 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 |
| 23 | OOAD diagrams, OpenAPI YAML drafted | 🔄 In Progress |
| 4 | Spring Boot: Auth, Pairing, Entity, Migration V4V16 | 🔄 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
hs_err_pid17212.log Normal file

File diff suppressed because it is too large Load Diff

View File

@ -46,16 +46,14 @@ package "③ Facade Pattern [Structural]" #FFF8E1 {
class "VoiceCommandHandler\n<<Facade>>" as VoiceCommandHandler {
- _ttsService : TtsService
- _sttService : SttService
- _router : GoRouter
- _walkGuideBloc : WalkGuideBloc
- _sosBloc : SosBloc
- _notifBloc : NotificationBloc
- _router : CommandRouter
- _actions : Map<VoiceCommandKey, CommandAction>
+ processText(String command) : void
- _matchCommand(String) : VoiceCommandKey?
- _executeCommand(VoiceCommandKey) : void
}
class "WalkGuideBloc\n<<Client>>" as WalkGuideBlocFacade {
class "WalkGuideCubit\n<<Client>>" as WalkGuideCubitFacade {
+ onVoiceCommand(String text)
}
@ -69,8 +67,8 @@ package "③ Facade Pattern [Structural]" #FFF8E1 {
class "SttService " as SttServiceFacade <<Subsystem>>
class "TtsService " as TtsServiceFacade <<Subsystem>>
class "GoRouter\n<<Router>>" as GoRouterFacade <<Subsystem>>
class "SosBloc " as SosBlocFacade <<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>>
@ -82,11 +80,11 @@ package "③ Facade Pattern [Structural]" #FFF8E1 {
' GET /api/v1/guardian/dashboard
}
WalkGuideBlocFacade --> VoiceCommandHandler : processText()
WalkGuideCubitFacade --> VoiceCommandHandler : processText()
VoiceCommandHandler --> SttServiceFacade : delegates
VoiceCommandHandler --> TtsServiceFacade : delegates
VoiceCommandHandler --> GoRouterFacade : delegates
VoiceCommandHandler --> SosBlocFacade : delegates
VoiceCommandHandler --> CommandActionFacade : delegates
GuardianDashboardController --> GuardianDashboardService : getDashboard()
GuardianDashboardService --> LocationService : aggregates

View File

@ -57,8 +57,8 @@ package "④ Repository Pattern [Structural — Proxy]" #FFF3E0 {
}
class "WalkGuideRepositoryImpl\n<<Proxy>>" as WalkGuideRepoImpl {
- _remoteDataSource : WalkGuideRemoteDataSource
- _localDataSource : WalkGuideLocalDataSource
- _apiClient : ApiClient
- _offlineQueue : OfflineQueueService
- _connectivity : ConnectivityPlus
+ startSession() : Either<Failure, void>
+ logObstacle(req) : Either<Failure, void>
@ -72,16 +72,16 @@ package "④ Repository Pattern [Structural — Proxy]" #FFF3E0 {
+ syncPending() : Either<Failure, void>
}
class "WalkGuideRemoteDataSource\n<<Remote>>" as RemoteDSWalk {
class "ApiClient\n<<Remote>>" as RemoteDSWalk {
+ startSession() : void
+ logObstacle(req) : void
' POST /api/v1/user/obstacle
}
class "WalkGuideLocalDataSource\n<<SQLite/Drift>>" as LocalDSWalk {
class "OfflineQueueService + LocalDatabase\n<<SQLite Cache>>" as LocalDSWalk {
+ cacheObstacle(ObstacleLog) : void
+ getPendingLogs() : List<ObstacleLog>
' Drift ORM — offline first
' SQLite-backed offline first
}
WalkGuideRepo <|.. WalkGuideRepoImpl : implements

View File

@ -43,30 +43,27 @@ skinparam note {
package "⑤ Observer Pattern [Behavioral]" #F3E5F5 {
abstract class "Bloc<Event, State>\n<<Subject>>" as BlocSubject {
abstract class "Cubit<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)
class "WalkGuideCubit\n<<ConcreteSubject>>" as WalkGuideCubitObs {
+ start()
+ stop()
+ logObstacle()
- _yoloDetector : YoloDetector
- _ttsService : TtsService
- _hapticService : HapticService
}
class "BlocBuilder<WalkGuideBloc, WalkGuideState>\n<<Observer / Widget>>" as BlocBuilderWidget {
class "BlocBuilder<WalkGuideCubit, 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 {
class "BlocListener<WalkGuideCubit, WalkGuideState>\n<<Observer / SideEffect>>" as BlocListenerWidget {
+ listener(ctx, state) : void
' Side effects: TTS, haptic, navigation
}
@ -84,9 +81,9 @@ package "⑤ Observer Pattern [Behavioral]" #F3E5F5 {
' Updates flutter_map markers in real-time
}
BlocSubject <|-- WalkGuideBlocObs : extends
WalkGuideBlocObs --> BlocBuilderWidget : emits state\n(rebuilds UI)
WalkGuideBlocObs --> BlocListenerWidget : emits state\n(side effects)
BlocSubject <|-- WalkGuideCubitObs : extends
WalkGuideCubitObs --> BlocBuilderWidget : emits state\n(rebuilds UI)
WalkGuideCubitObs --> BlocListenerWidget : emits state\n(side effects)
WebSocketObs --> GuardianMapObs : notifies\nlive location
}

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>

View File

@ -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

View File

@ -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());
}
}
}

View File

@ -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();
}
}

View File

@ -14,9 +14,12 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
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;
@ -36,35 +39,80 @@ public class CallController {
@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"));
}
@PostMapping("/notify")
@Operation(summary = "Notify receiver of incoming call")
public ResponseEntity<ApiResponse<Void>> notifyCall(
@Valid @RequestBody CallNotifyRequest req) {
public ResponseEntity<ApiResponse<Void>> notifyCall(@Valid @RequestBody CallNotifyRequest req) {
Long callerId = SecurityHelper.getCurrentUserId();
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"));
}
@PostMapping("/end")
@Operation(summary = "Notify end of call")
public ResponseEntity<ApiResponse<Void>> endCall(
@RequestBody Map<String, Long> body) {
public ResponseEntity<ApiResponse<Void>> endCall(@RequestBody Map<String, String> body) {
Long callerId = SecurityHelper.getCurrentUserId();
Long otherId = body.get("otherId");
callNotificationService.notifyCallEnded(callerId, otherId);
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"));
}
}

View File

@ -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;
}

View File

@ -8,4 +8,5 @@ public class LocationUpdateRequest {
private Double accuracy;
private Double speed;
private Double heading;
private Integer batteryLevel;
}

View File

@ -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;

View File

@ -16,5 +16,6 @@ public class LocationResponse {
private Double accuracy;
private Double speed;
private Double heading;
private Integer batteryLevel;
private LocalDateTime createdAt;
}

View File

@ -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;

View File

@ -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)

View File

@ -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);
}

View File

@ -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);
}
}
}

View File

@ -4,11 +4,14 @@ 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
@ -17,29 +20,39 @@ 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 (receiver mungkin tidak menerima push notification)";
return "Panggilan dikirim via realtime fallback.";
}
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
Map<String, String> payload = Map.of(
"type", "INCOMING_CALL",
"callerId", String.valueOf(callerId),
"callerName", callerName,
"channelName", req.getChannelName(),
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
"receiverUid", String.valueOf(req.getReceiverUid())
);
fcmService.sendHighPriority(
receiver.getFcmToken(),
"Panggilan Masuk",
@ -52,22 +65,111 @@ public class CallNotificationService {
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",
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
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);
}
}

View File

@ -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);
}
}

View File

@ -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();
}

View File

@ -42,6 +42,7 @@ public class LocationService {
.accuracy(req.getAccuracy())
.speed(req.getSpeed())
.heading(req.getHeading())
.batteryLevel(req.getBatteryLevel())
.build();
loc = locationHistoryRepository.save(loc);
@ -136,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();
}
}

View File

@ -7,7 +7,6 @@ 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;
@ -18,7 +17,6 @@ import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class PairingService {
private final PairingRelationRepository pairingRelationRepository;
@ -34,6 +32,22 @@ public class PairingService {
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 PairingCodeResponse getOrCreatePairingCode(Long userId) {
User user = userRepository.findById(userId)
@ -69,7 +83,6 @@ public class PairingService {
@Transactional
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
// Guardian tidak boleh punya pairing ACTIVE atau PENDING
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
}
@ -88,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)
@ -99,11 +158,7 @@ public class PairingService {
user.setPairingCodeExpiresAt(null);
userRepository.save(user);
// 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()));
sendPairingInviteNotification(pairing, guardian, user);
activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT,
"Guardian mengirim invite ke " + user.getDisplayName(), null);
@ -195,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"),
@ -261,6 +323,15 @@ public class PairingService {
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 {
@ -307,3 +378,4 @@ public class PairingService {
.build();
}
}

View File

@ -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;
}

View File

@ -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"));
}
}

View File

@ -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

View File

@ -6,9 +6,18 @@
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
username: ${DB_USERNAME:5803024001}
password: ${DB_PASSWORD:pw5803024001}
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
@ -17,12 +26,12 @@ spring:
format_sql: true
jwt:
secret: ${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt}
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:

View File

@ -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:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
spring.datasource.username=${DB_USERNAME:5803024001}
spring.datasource.password=${DB_PASSWORD:pw5803024001}
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
@ -19,7 +27,7 @@ spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
# ===== JWT =====
jwt.secret=${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt}
jwt.secret=${JWT_SECRET}
jwt.expiration=${JWT_EXPIRATION:86400000}
# ===== SWAGGER =====
@ -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

View File

@ -0,0 +1,2 @@
ALTER TABLE location_history
ADD COLUMN IF NOT EXISTS battery_level INTEGER;

View File

@ -4,6 +4,7 @@ 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;
@ -34,6 +35,9 @@ class CallNotificationServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private LocationBroadcaster locationBroadcaster;
@InjectMocks
private CallNotificationService service;
@ -89,7 +93,7 @@ class CallNotificationServiceTest {
String message = service.notifyIncomingCall(1L, request);
assertEquals("Panggilan dikirim (receiver mungkin tidak menerima push notification)", message);
assertEquals("Panggilan dikirim via realtime fallback.", message);
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}

View File

@ -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,12 +148,27 @@ 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 =====

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -77,4 +77,4 @@ vase
scissors
teddy bear
hair drier
toothbrush
toothbrush

View File

@ -1,97 +1,172 @@
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,
brightness: Brightness.light,
),
scaffoldBackgroundColor: const Color(0xFFF4F7FB),
textTheme: GoogleFonts.interTextTheme(),
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
},
),
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: Color(0xFFF4F7FB),
foregroundColor: Color(0xFF0F172A),
elevation: 0,
surfaceTintColor: Colors.transparent,
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
height: 76,
backgroundColor: Colors.white.withValues(alpha: 0.96),
indicatorColor: const Color(0xFFE0E7FF),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
fontSize: 12,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w800
: FontWeight.w500,
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),
),
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: seed,
foregroundColor: Colors.white,
minimumSize: const Size(0, 50),
textStyle: const TextStyle(fontWeight: FontWeight.w800),
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),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 50),
foregroundColor: seed,
textStyle: const TextStyle(fontWeight: FontWeight.w800),
side: const BorderSide(color: Color(0xFFCBD5E1)),
shape: RoundedRectangleBorder(
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: const Color(0xFFF8FAFC),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: seed, width: 1.5),
),
),
),

View File

@ -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));
}

View File

@ -1,6 +1,4 @@
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';
@ -10,6 +8,7 @@ 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';
@ -18,7 +17,6 @@ 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 '../core/utils/init_guard.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';
@ -39,17 +37,24 @@ Future<void> initDependencies() async {
sl.registerLazySingleton<SttService>(() => SttService());
sl.registerLazySingleton<HapticService>(() => HapticService());
sl.registerLazySingleton<ObstacleAlertStrategy>(
() => TtsWithHapticObstacleAlertStrategy(sl<TtsService>(), sl<HapticService>()),
() => TtsWithHapticObstacleAlertStrategy(
sl<TtsService>(), sl<HapticService>()),
);
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>()));
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>()),
);
@ -59,8 +64,10 @@ Future<void> initDependencies() async {
sl.registerLazySingleton<WalkGuideRepository>(
() => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()),
);
sl.registerFactory<WalkGuideCubit>(() => WalkGuideCubit(sl<WalkGuideRepository>()));
sl.registerLazySingleton<SosRepository>(() => SosRepositoryImpl(sl<ApiClient>()));
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>()),
@ -74,13 +81,5 @@ Future<void> initDependencies() async {
await sl<ApiClient>().init(serverUrl);
}
await ignoreInitFailure(() => sl<TtsService>().init(), label: 'TTS init');
await sl<YoloDetector>().init();
if (!kIsWeb) {
await ignoreInitFailure(() => sl<SttService>().init(), label: 'STT init');
}
sl<VoiceCommandHandler>().loadDefaultCommands();
if (!kIsWeb) {
await sl<FcmService>().init();
}
}

View File

@ -25,11 +25,13 @@ import '../features/guardian_dashboard/presentation/screens/guardian_tools_scree
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/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'
@ -40,10 +42,12 @@ import '../features/walk_guide/presentation/screens/walk_guide_screen.dart'
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' ||
@ -52,7 +56,8 @@ final GoRouter appRouter = GoRouter(
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';
@ -87,7 +92,9 @@ final GoRouter appRouter = GoRouter(
routes: [
GoRoute(
path: '/server-connect',
builder: (_, __) => const server_connect.ServerConnectScreen()),
builder: (_, state) => server_connect.ServerConnectScreen(
editMode: state.uri.queryParameters['edit'] == '1',
)),
GoRoute(
path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()),
@ -96,7 +103,18 @@ final GoRouter appRouter = GoRouter(
builder: (_, __) => const auth_register.RegisterScreen()),
GoRoute(
path: '/incoming-call',
builder: (_, __) => const call.IncomingCallScreen()),
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: [
@ -124,6 +142,9 @@ final GoRouter appRouter = GoRouter(
GoRoute(
path: '/user/benchmark',
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
GoRoute(
path: '/user/manual',
builder: (_, __) => const manual.ManualScreen()),
],
),
ShellRoute(
@ -161,6 +182,12 @@ final GoRouter appRouter = GoRouter(
path: '/guardian/settings',
builder: (_, __) =>
const guardian_settings.GuardianSettingsScreen()),
GoRoute(
path: '/guardian/call',
builder: (_, __) => const call.CallScreen(
targetLabel: 'User',
returnRoute: '/guardian/dashboard',
)),
GoRoute(
path: '/guardian/benchmark',
builder: (_, __) => const benchmark.AiBenchmarkScreen()),

View File

@ -586,14 +586,31 @@ const Set<String> _walkGuideObstacleLabels = {
'bicycle',
'car',
'motorcycle',
'truck',
'bus',
'train',
'truck',
'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',
@ -608,6 +625,7 @@ const Set<String> _walkGuideObstacleLabels = {
'bottle',
'cup',
'book',
'object',
};
const Map<int, String> _cocoObstacleLabels = {

View File

@ -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');
}

View File

@ -71,6 +71,12 @@ bool _looksTechnical(String message) {
'null check operator',
'nosuchmethod',
'formatexception',
'could not execute statement',
'duplicate key',
'constraint',
'sql [',
'illegal base64',
'base64 character',
];
return blocked.any(lower.contains);
}

View File

@ -1,9 +1,19 @@
import 'package:flutter/widgets.dart';
class AppStrings {
final String localeCode;
const AppStrings(this.localeCode);
static const supportedLocales = ['id-ID', 'en-US'];
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',
@ -29,3 +39,21 @@ class AppStrings {
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;
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -1,32 +1,62 @@
import 'dart:convert';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../../app/router.dart';
import '../network/api_client.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
}
class FcmService {
final ApiClient _apiClient;
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
FcmService(this._apiClient);
Future<void> init() async {
if (kIsWeb) return;
try {
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(
_firebaseMessagingBackgroundHandler);
final messaging = FirebaseMessaging.instance;
await _localNotifications.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
onDidReceiveNotificationResponse: (response) {
final payload = response.payload;
if (payload == null || payload.isEmpty) return;
try {
final data = Map<String, dynamic>.from(jsonDecode(payload) as Map);
_handlePayloadNavigation(data);
} catch (_) {}
},
);
await _messaging.requestPermission(alert: true, badge: true, sound: true);
final token = await _messaging.getToken();
await messaging.requestPermission(alert: true, badge: true, sound: true);
final token = await messaging.getToken();
if (token != null) await syncToken(token);
FirebaseMessaging.instance.onTokenRefresh.listen(syncToken);
messaging.onTokenRefresh.listen(syncToken);
FirebaseMessaging.onMessage.listen((message) {
debugPrint('FCM foreground: ${message.data}');
_showLocalNotification(message);
_handlePayloadNavigation(message.data);
});
FirebaseMessaging.onMessageOpenedApp.listen((message) {
_handlePayloadNavigation(message.data);
});
final initialMessage =
await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
_handlePayloadNavigation(initialMessage.data);
}
} catch (e) {
debugPrint('FCM init skipped: $e');
}
@ -34,6 +64,10 @@ class FcmService {
Future<void> syncToken(String token) async {
try {
if (_apiClient.baseUrl == null) {
debugPrint('FCM token sync skipped: server URL is not ready.');
return;
}
await _apiClient.dio.put('/auth/fcm-token', data: {'fcmToken': token});
} catch (e) {
debugPrint('FCM token sync skipped: $e');
@ -42,8 +76,11 @@ class FcmService {
Future<void> _showLocalNotification(RemoteMessage message) async {
final notification = message.notification;
final title = notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
final body = notification?.body ?? message.data['body']?.toString() ?? 'Ada update baru';
final title =
notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
final body = notification?.body ??
message.data['body']?.toString() ??
'Ada update baru';
await _localNotifications.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title,
@ -57,7 +94,26 @@ class FcmService {
priority: Priority.high,
),
),
payload: message.data['type']?.toString(),
payload: jsonEncode(message.data),
);
}
void _handlePayloadNavigation(Map<String, dynamic> data) {
final type = data['type']?.toString();
if (type == 'INCOMING_CALL') {
appRouter.go('/incoming-call', extra: data);
return;
}
if (type == 'SOS_ALERT') {
appRouter.go('/guardian/dashboard');
return;
}
if (type == 'PAIRING_INVITE' || type == 'PAIRING_RESPONSE') {
appRouter.go('/user/pairing');
return;
}
if (type == 'NOTIFICATION') {
appRouter.go('/user/notifications');
}
}
}

View File

@ -1,36 +1,107 @@
import 'package:flutter/services.dart';
import 'package:vibration/vibration.dart';
class HapticService {
Future<bool> get _hasVibrator async => Vibration.hasVibrator();
bool _enabled = true;
DateTime _lastObstacleVibrationAt = DateTime.fromMillisecondsSinceEpoch(0);
static const _obstacleCooldown = Duration(seconds: 3);
bool get enabled => _enabled;
void setEnabled(bool enabled) {
_enabled = enabled;
if (!enabled) {
Vibration.cancel().ignore();
}
}
Future<bool> get _hasVibrator async {
try {
final hasVibrator = await Vibration.hasVibrator();
return hasVibrator == true;
} catch (_) {
return false;
}
}
bool _canRunObstacleVibration() {
final now = DateTime.now();
if (now.difference(_lastObstacleVibrationAt) < _obstacleCooldown) {
return false;
}
_lastObstacleVibrationAt = now;
return true;
}
Future<void> _vibrate({
int? duration,
List<int>? pattern,
required Future<void> Function() fallback,
bool obstacle = false,
}) async {
if (!_enabled) return;
if (obstacle && !_canRunObstacleVibration()) return;
try {
if (await _hasVibrator) {
if (pattern != null) {
await Vibration.vibrate(pattern: pattern);
} else if (duration != null) {
await Vibration.vibrate(duration: duration);
}
return;
}
} catch (_) {
// Use Flutter's platform haptics below when the vibration plugin fails.
}
await fallback();
}
Future<void> obstacleVeryClose() async {
if (!await _hasVibrator) return;
Vibration.vibrate(pattern: [0, 500, 100, 500, 100, 500]);
await _vibrate(
pattern: [0, 500, 100, 500, 100, 500],
fallback: HapticFeedback.heavyImpact,
obstacle: true,
);
}
Future<void> obstacleClose() async {
if (!await _hasVibrator) return;
Vibration.vibrate(pattern: [0, 300, 100, 300]);
await _vibrate(
pattern: [0, 300, 100, 300],
fallback: HapticFeedback.mediumImpact,
obstacle: true,
);
}
Future<void> obstacleMedium() async {
if (!await _hasVibrator) return;
Vibration.vibrate(duration: 150);
await _vibrate(
duration: 150,
fallback: HapticFeedback.lightImpact,
obstacle: true,
);
}
Future<void> sosTriggered() async {
if (!await _hasVibrator) return;
Vibration.vibrate(pattern: [0, 1000, 200, 1000, 200, 1000]);
await _vibrate(
pattern: [0, 1000, 200, 1000, 200, 1000],
fallback: HapticFeedback.heavyImpact,
);
}
Future<void> callIncoming() async {
if (!await _hasVibrator) return;
Vibration.vibrate(pattern: [0, 500, 500, 500, 500, 500, 500, 500]);
await _vibrate(
pattern: [0, 500, 500, 500, 500, 500, 500, 500],
fallback: HapticFeedback.mediumImpact,
);
}
Future<void> success() async {
if (!await _hasVibrator) return;
Vibration.vibrate(duration: 80);
await _vibrate(
duration: 80,
fallback: HapticFeedback.selectionClick,
);
}
Future<void> stop() async => Vibration.cancel();

View File

@ -27,11 +27,14 @@ class HardwareShortcutBinding {
class HardwareShortcutListener {
final ApiClient _apiClient;
final Map<int, HardwareShortcutBinding> _bindings = {};
final Map<int, DateTime> _lastHandledAt = {};
bool _listening = false;
void Function(HardwareShortcutAction action)? _onAction;
void Function(int buttonCode, String buttonName)? _captureCallback;
static const Duration _repeatDebounce = Duration(milliseconds: 900);
HardwareShortcutListener(this._apiClient);
Future<void> startListening({
@ -68,7 +71,8 @@ class HardwareShortcutListener {
);
}
void captureNextButton(void Function(int buttonCode, String buttonName) onCapture) {
void captureNextButton(
void Function(int buttonCode, String buttonName) onCapture) {
_captureCallback = onCapture;
}
@ -88,6 +92,12 @@ class HardwareShortcutListener {
final binding = _bindings[code];
if (binding == null || !binding.enabled) return false;
final now = DateTime.now();
final lastHandled = _lastHandledAt[code];
if (lastHandled != null && now.difference(lastHandled) < _repeatDebounce) {
return true;
}
_lastHandledAt[code] = now;
_onAction?.call(binding.action);
return true;
}
@ -103,7 +113,8 @@ HardwareShortcutBinding? _bindingFromJson(Map<String, dynamic> item) {
final action = _actionFromBackend(item['shortcutKey']?.toString());
final rawCode = item['buttonCode'];
final enabled = item['enabled'] != false;
final code = rawCode is int ? rawCode : int.tryParse(rawCode?.toString() ?? '');
final code =
rawCode is int ? rawCode : int.tryParse(rawCode?.toString() ?? '');
if (action == null || code == null || code <= 0) return null;
return HardwareShortcutBinding(
action: action,

View File

@ -0,0 +1,45 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../app/router.dart';
import '../network/api_client.dart';
class IncomingCallPollingService {
IncomingCallPollingService(this._apiClient);
final ApiClient _apiClient;
Timer? _timer;
String? _lastChannel;
void start() {
if (_timer != null) return;
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _check());
unawaited(_check());
}
void stop() {
_timer?.cancel();
_timer = null;
_lastChannel = null;
}
Future<void> _check() async {
try {
final res = await _apiClient.dio
.get('/shared/call/pending')
.timeout(const Duration(seconds: 2));
final data = res.data['data'];
if (data is! Map) return;
if (data['type']?.toString() != 'INCOMING_CALL') return;
final channel = data['channelName']?.toString();
if (channel == null || channel.isEmpty || channel == _lastChannel) return;
_lastChannel = channel;
appRouter.go('/incoming-call', extra: Map<String, dynamic>.from(data));
} catch (e) {
debugPrint('Incoming call polling skipped: $e');
}
}
}

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:battery_plus/battery_plus.dart';
import 'package:dio/dio.dart';
import 'package:geolocator/geolocator.dart';
@ -10,6 +11,7 @@ import 'offline_queue_service.dart';
class LocationReporterService {
final ApiClient _apiClient;
final OfflineQueueService _offlineQueue;
final Battery _battery = Battery();
Timer? _timer;
LocationReporterService(this._apiClient, this._offlineQueue);
@ -32,12 +34,14 @@ class LocationReporterService {
try {
await Geolocator.requestPermission();
final position = await Geolocator.getCurrentPosition();
final batteryLevel = await _readBatteryLevel();
await _apiClient.dio.post('/user/location', data: {
'lat': position.latitude,
'lng': position.longitude,
'accuracy': position.accuracy,
'speed': position.speed,
'heading': position.heading,
if (batteryLevel != null) 'batteryLevel': batteryLevel,
});
} on DioException catch (_) {
await _offlineQueue.enqueue(OfflineRequest(
@ -50,4 +54,12 @@ class LocationReporterService {
// GPS permission can be unavailable during desktop/web testing.
}
}
Future<int?> _readBatteryLevel() async {
try {
return await _battery.batteryLevel;
} catch (_) {
return null;
}
}
}

View File

@ -6,17 +6,24 @@ class SttService {
final SpeechToText _stt = SpeechToText();
bool _available = false;
bool _listening = false;
bool _shouldListen = false;
bool _initializing = false;
Function(String)? onResult;
Future<bool> init() async {
if (_available) return true;
if (_initializing) return _available;
_initializing = true;
_available = await _stt.initialize(
onError: (e) => _onError(e),
onStatus: (s) => _onStatus(s),
);
_initializing = false;
return _available;
}
Future<void> startListening() async {
_shouldListen = true;
if (!_available || _listening) return;
_listening = true;
await _stt.listen(
@ -25,14 +32,15 @@ class SttService {
onResult?.call(result.recognizedWords.toLowerCase().trim());
}
},
listenFor: const Duration(seconds: 10),
pauseFor: const Duration(seconds: 3),
listenFor: const Duration(seconds: 60),
pauseFor: const Duration(seconds: 8),
localeId: 'id_ID',
cancelOnError: false,
);
}
Future<void> stopListening() async {
_shouldListen = false;
_listening = false;
await _stt.stop();
}
@ -42,15 +50,17 @@ class SttService {
void _onError(dynamic error) {
_listening = false;
// Auto-restart setelah error
Future.delayed(const Duration(seconds: 1), startListening);
if (_shouldListen) {
Future.delayed(const Duration(seconds: 2), startListening);
}
}
void _onStatus(String status) {
if (status == 'done' || status == 'notListening') {
_listening = false;
// Auto-restart agar selalu mendengarkan
Future.delayed(const Duration(milliseconds: 500), startListening);
if (_shouldListen) {
Future.delayed(const Duration(seconds: 2), startListening);
}
}
}
}

View File

@ -4,9 +4,15 @@ class TtsService {
final FlutterTts _tts = FlutterTts();
final List<String> _queue = [];
bool _speaking = false;
bool _initialized = false;
String _lastSpoken = '';
DateTime _lastQueuedAt = DateTime.fromMillisecondsSinceEpoch(0);
Future<void> init({String language = 'id-ID', double pitch = 1.0, double rate = 0.5}) async {
Future<void> init(
{String language = 'id-ID',
double pitch = 1.0,
double rate = 0.5}) async {
if (_initialized) return;
await _tts.setLanguage(language);
await _tts.setPitch(pitch);
await _tts.setSpeechRate(rate);
@ -15,11 +21,25 @@ class TtsService {
_speaking = false;
_processQueue();
});
_initialized = true;
}
/// Tambah ke antrian - tidak memotong yg sedang bicara
void speak(String text) {
if (text.isEmpty) return;
if (!_initialized) {
init().then((_) => speak(text));
return;
}
final now = DateTime.now();
if (text == _lastSpoken &&
now.difference(_lastQueuedAt) < const Duration(milliseconds: 900)) {
return;
}
_lastQueuedAt = now;
if (_queue.length >= 3) {
_queue.removeAt(0);
}
_queue.add(text);
if (!_speaking) _processQueue();
}
@ -27,6 +47,7 @@ class TtsService {
/// Potong TTS sekarang dan langsung ucapkan ini - untuk obstacle alert
Future<void> speakImmediate(String text) async {
if (text.isEmpty) return;
await init();
_queue.clear();
await _tts.stop();
_speaking = true;
@ -43,9 +64,20 @@ class TtsService {
String get lastSpoken => _lastSpoken;
bool get isSpeaking => _speaking;
Future<void> setLanguage(String lang) async => _tts.setLanguage(lang);
Future<void> setPitch(double pitch) async => _tts.setPitch(pitch);
Future<void> setRate(double rate) async => _tts.setSpeechRate(rate);
Future<void> setLanguage(String lang) async {
await init(language: lang);
await _tts.setLanguage(lang);
}
Future<void> setPitch(double pitch) async {
await init();
await _tts.setPitch(pitch);
}
Future<void> setRate(double rate) async {
await init();
await _tts.setSpeechRate(rate);
}
void repeatLast() {
if (_lastSpoken.isNotEmpty) speak(_lastSpoken);
@ -58,4 +90,4 @@ class TtsService {
_lastSpoken = text;
_tts.speak(text);
}
}
}

View File

@ -19,6 +19,8 @@ class VoiceCommand {
/// Callback yang dipanggil saat command terdeteksi
/// Registered oleh router/screen yang relevan
typedef CommandCallback = void Function(VoiceCommandKey key);
typedef CommandRouter = void Function(String route);
typedef CommandAction = void Function();
class VoiceCommandHandler {
final SttService _stt;
@ -26,9 +28,19 @@ class VoiceCommandHandler {
List<VoiceCommand> _commands = [];
CommandCallback? onCommand;
CommandRouter? _router;
final Map<VoiceCommandKey, CommandAction> _actions = {};
VoiceCommandHandler(this._stt, this._tts);
void registerRouter(CommandRouter router) {
_router = router;
}
void registerAction(VoiceCommandKey key, CommandAction action) {
_actions[key] = action;
}
void loadCommands(List<VoiceCommand> commands) {
_commands = commands;
_stt.onResult = _processText;
@ -66,9 +78,28 @@ class VoiceCommandHandler {
}
void _handleCommand(VoiceCommandKey key) {
_routeFor(key);
_actions[key]?.call();
onCommand?.call(key);
// Built-in actions for TTS-only commands
if (key == VoiceCommandKey.repeatLast) _tts.repeatLast();
if (key == VoiceCommandKey.stopTts) _tts.stop();
}
void _routeFor(VoiceCommandKey key) {
final route = switch (key) {
VoiceCommandKey.openWalkguide || VoiceCommandKey.startWalkguide =>
'/user/walkguide',
VoiceCommandKey.openNotification => '/user/notifications',
VoiceCommandKey.openSos || VoiceCommandKey.sendSos => '/user/sos',
VoiceCommandKey.openActivity => '/user/activity',
VoiceCommandKey.openNavigation => '/user/navigation',
VoiceCommandKey.openSettings => '/user/settings',
VoiceCommandKey.callGuardian => '/call',
_ => null,
};
if (route != null) {
_router?.call(route);
}
}
}

View File

@ -13,9 +13,9 @@ import '../storage/secure_storage.dart';
/// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user.
///
/// Subscriptions yang dipakai:
/// Guardian /topic/location/{userId} live GPS update
/// Guardian /queue/sos/{guardianId} SOS alert real-time
/// User /queue/notif/{userId} notifikasi dari Guardian
/// Guardian â /topic/location/{userId} live GPS update
/// Guardian â /queue/sos/{guardianId} SOS alert real-time
/// User â /queue/notif/{userId} notifikasi dari Guardian
class WebSocketService {
final SecureStorage _storage;
@ -26,11 +26,13 @@ class WebSocketService {
void Function(double lat, double lng)? _onLocation;
void Function(Map<String, dynamic> sosData)? _onSos;
void Function(Map<String, dynamic> notifData)? _onNotif;
void Function(Map<String, dynamic> callData)? _onCall;
// Subscription frames (untuk unsubscribe)
StompUnsubscribe? _locationUnsub;
StompUnsubscribe? _sosUnsub;
StompUnsubscribe? _notifUnsub;
StompUnsubscribe? _callUnsub;
WebSocketService(this._storage);
@ -88,18 +90,18 @@ class WebSocketService {
await completer.future.timeout(const Duration(seconds: 5));
} catch (e) {
debugPrint('[WS] Connect timeout/error: $e');
// Don't throw let dashboard work without WS
// Don't throw — let dashboard work without WS
}
}
/// Subscribe ke live GPS updates dari User.
/// Guardian panggil ini setelah connect.
/// [userId] = ID dari ROLE_USER yang dipair.
void subscribeLocation(String userId,
void Function(double lat, double lng) callback) {
void subscribeLocation(
String userId, void Function(double lat, double lng) callback) {
_onLocation = callback;
if (_client == null || !_connected) {
debugPrint('[WS] subscribeLocation skipped not connected');
debugPrint('[WS] subscribeLocation skipped — not connected');
return;
}
_locationUnsub?.call(); // unsubscribe sebelumnya jika ada
@ -107,8 +109,7 @@ class WebSocketService {
destination: '/topic/location/$userId',
callback: (frame) {
try {
final data =
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
final lat = (data['lat'] as num?)?.toDouble();
final lng = (data['lng'] as num?)?.toDouble();
if (lat != null && lng != null) {
@ -135,8 +136,7 @@ class WebSocketService {
destination: '/queue/sos/$guardianId',
callback: (frame) {
try {
final data =
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
_onSos?.call(data);
} catch (e) {
debugPrint('[WS] SOS parse error: $e');
@ -147,7 +147,7 @@ class WebSocketService {
});
}
/// Subscribe ke notifikasi Guardian User.
/// Subscribe ke notifikasi Guardian â User.
/// [userId] = ID dari ROLE_USER yang login.
void subscribeNotification(
void Function(Map<String, dynamic> notifData) callback) {
@ -161,8 +161,7 @@ class WebSocketService {
destination: '/queue/notif/$userId',
callback: (frame) {
try {
final data =
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
_onNotif?.call(data);
} catch (e) {
debugPrint('[WS] Notif parse error: $e');
@ -173,20 +172,46 @@ class WebSocketService {
});
}
/// Subscribe ke panggilan masuk realtime. Ini melengkapi FCM agar call tetap
/// masuk saat app foreground atau ketika FCM di app clone tidak stabil.
void subscribeCall(void Function(Map<String, dynamic> callData) callback) {
_onCall = callback;
if (_client == null || !_connected) return;
_storage.getUserId().then((userId) {
if (userId == null) return;
_callUnsub?.call();
_callUnsub = _client!.subscribe(
destination: '/queue/call/$userId',
callback: (frame) {
try {
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
_onCall?.call(data);
} catch (e) {
debugPrint('[WS] Call parse error: $e');
}
},
);
debugPrint('[WS] Subscribed to /queue/call/$userId');
});
}
/// Disconnect dan cleanup semua subscriptions.
Future<void> disconnect() async {
_locationUnsub?.call();
_sosUnsub?.call();
_notifUnsub?.call();
_callUnsub?.call();
_locationUnsub = null;
_sosUnsub = null;
_notifUnsub = null;
_callUnsub = null;
_client?.deactivate();
_client = null;
_connected = false;
}
// Legacy compat lama pakai onMessage raw
// Legacy compat â lama pakai onMessage raw
void send(Object message) {
debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.');
}

View File

@ -1,10 +1,28 @@
import 'package:flutter/material.dart';
class AppColors {
static const primary = Color(0xFF1A56DB);
static const primaryBlue = Color(0xFF4A90D9);
static const softBlueBg = Color(0xFFEBF4FF);
static const softPinkBg = Color(0xFFFFF0F5);
static const softYellowBg = Color(0xFFFFFBEB);
static const cardWhite = Color(0xFFFFFFFF);
static const textDark = Color(0xFF2D3748);
static const textMuted = Color(0xFFA0AEC0);
static const gold = Color(0xFFF6C90E);
static const gradientBlueStart = Color(0xFF6BB8FF);
static const gradientBlueEnd = Color(0xFF4A90D9);
static const gradientPinkStart = Color(0xFFFFB3C6);
static const gradientPinkEnd = Color(0xFFFF6B9D);
static const primary = primaryBlue;
static const primaryDark = Color(0xFF256FB8);
static const accent = Color(0xFFFF6B9D);
static const warning = Color(0xFFD97706);
static const danger = Color(0xFFDC2626);
static const success = Color(0xFF16A34A);
static const surface = Color(0xFFF8FAFC);
static const text = Color(0xFF0F172A);
static const muted = Color(0xFF64748B);
static const success = Color(0xFF059669);
static const surface = softBlueBg;
static const surfaceRaised = cardWhite;
static const text = textDark;
static const muted = textMuted;
static const border = Color(0xFFE2E8F0);
}

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppDecorations {
static const cardRadius = BorderRadius.all(Radius.circular(20));
static const pillRadius = BorderRadius.all(Radius.circular(50));
static const inputRadius = BorderRadius.all(Radius.circular(14));
static const iconCircleSize = 44.0;
static const cardShadow = [
BoxShadow(
color: Color(0x14000000),
blurRadius: 20,
offset: Offset(0, 4),
),
];
static const avatarShadow = [
BoxShadow(
color: Color(0x18000000),
blurRadius: 18,
offset: Offset(0, 6),
),
];
static const blueGradient = LinearGradient(
colors: [AppColors.gradientBlueStart, AppColors.gradientBlueEnd],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
static const pinkGradient = LinearGradient(
colors: [AppColors.gradientPinkStart, AppColors.gradientPinkEnd],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
static const cardShape = RoundedRectangleBorder(
borderRadius: cardRadius,
);
static BoxDecoration get card => const BoxDecoration(
color: AppColors.cardWhite,
borderRadius: cardRadius,
boxShadow: cardShadow,
);
static BoxDecoration pillGradient({
Gradient gradient = blueGradient,
}) =>
BoxDecoration(
gradient: gradient,
borderRadius: pillRadius,
);
static BoxDecoration iconCircle({
Color color = AppColors.softBlueBg,
}) =>
BoxDecoration(
color: color,
borderRadius: pillRadius,
);
static BoxDecoration avatar({
Color borderColor = Colors.white,
}) =>
BoxDecoration(
shape: BoxShape.circle,
color: AppColors.cardWhite,
border: Border.all(color: borderColor, width: 3),
boxShadow: avatarShadow,
);
}

View File

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
class AppTextStyles {
static TextStyle get heading => GoogleFonts.poppins(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.textDark,
letterSpacing: 0,
height: 1.2,
);
static TextStyle get subheading => GoogleFonts.poppins(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textDark,
letterSpacing: 0,
height: 1.25,
);
static TextStyle get body => GoogleFonts.poppins(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textDark,
letterSpacing: 0,
height: 1.45,
);
static TextStyle get caption => GoogleFonts.poppins(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.textMuted,
letterSpacing: 0,
height: 1.35,
);
static TextTheme get textTheme => GoogleFonts.poppinsTextTheme().copyWith(
headlineSmall: heading,
titleMedium: subheading,
bodyMedium: body,
bodySmall: caption,
labelLarge: body.copyWith(fontWeight: FontWeight.w700),
);
}

View File

@ -8,6 +8,9 @@ import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../core/theme/app_text_styles.dart';
import '../../shared/widgets/animations/animations.dart';
Dio get _api => sl<ApiClient>().dio;
@ -103,91 +106,114 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.softBlueBg, Colors.white],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: FadeSlideWrapper(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Activity Log',
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
// Header
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Activity Log',
style: AppTextStyles.heading,
),
Text(
'${_items.length} aktivitas tercatat',
style: const TextStyle(color: AppColors.muted),
),
],
),
Text(
'${_items.length} aktivitas tercatat',
style: const TextStyle(color: AppColors.muted),
),
],
),
IconButton(
onPressed: _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
const SizedBox(height: 12),
// Filter chips
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _filters.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final f = _filters[i];
final selected = _selectedFilter == f;
return FilterChip(
label: Text(f),
selected: selected,
onSelected: (_) {
setState(() => _applyFilter(f));
},
selectedColor:
AppColors.primary.withValues(alpha: 0.15),
backgroundColor: AppColors.cardWhite,
side: BorderSide(
color: selected
? AppColors.primary.withValues(alpha: 0.4)
: AppColors.border,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
checkmarkColor: AppColors.primary,
labelStyle: TextStyle(
color: selected ? AppColors.primary : AppColors.muted,
fontWeight:
selected ? FontWeight.w700 : FontWeight.normal,
fontSize: 12,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
);
},
),
),
IconButton(
onPressed: _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
const SizedBox(height: 16),
// Body
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? _ErrorPanel(message: _error!, onRetry: _load)
: _filtered.isEmpty
? _EmptyPanel(filter: _selectedFilter)
: RefreshIndicator(
onRefresh: _load,
child: ListView(
children: [
StaggerWrapper(
children: [
for (final item in _filtered)
_LogCard(item: item),
],
),
],
),
),
),
],
),
const SizedBox(height: 12),
// Filter chips
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _filters.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final f = _filters[i];
final selected = _selectedFilter == f;
return FilterChip(
label: Text(f),
selected: selected,
onSelected: (_) {
setState(() => _applyFilter(f));
},
selectedColor: AppColors.primary.withValues(alpha: 0.15),
checkmarkColor: AppColors.primary,
labelStyle: TextStyle(
color: selected ? AppColors.primary : AppColors.muted,
fontWeight:
selected ? FontWeight.w700 : FontWeight.normal,
fontSize: 12,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
);
},
),
),
const SizedBox(height: 16),
// Body
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? _ErrorPanel(message: _error!, onRetry: _load)
: _filtered.isEmpty
? _EmptyPanel(filter: _selectedFilter)
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
itemCount: _filtered.length,
itemBuilder: (ctx, i) =>
_LogCard(item: _filtered[i]),
),
),
),
],
),
),
),
);
@ -228,71 +254,76 @@ class _LogCard extends StatelessWidget {
Widget build(BuildContext context) {
final meta = _logMeta(item.logType);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline dot + line
Column(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: meta.color.withValues(alpha: 0.12),
shape: BoxShape.circle,
padding: const EdgeInsets.only(bottom: 10),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline dot + line
Column(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: meta.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(50),
),
child: Icon(meta.icon, color: meta.color, size: 18),
),
child: Icon(meta.icon, color: meta.color, size: 18),
),
Container(
width: 1.5,
height: 20,
color: const Color(0xFFE2E8F0),
),
],
),
const SizedBox(width: 12),
// Content
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
meta.label,
style: TextStyle(
fontWeight: FontWeight.w700,
color: meta.color,
fontSize: 13,
],
),
const SizedBox(width: 12),
// Content
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
meta.label,
style: TextStyle(
fontWeight: FontWeight.w700,
color: meta.color,
fontSize: 13,
),
),
),
),
Text(
_formatTime(item.createdAt),
style: const TextStyle(
color: AppColors.muted, fontSize: 11),
),
],
),
if (item.description != null && item.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
item.description!,
style: const TextStyle(
fontSize: 13, color: AppColors.text),
),
Text(
_formatTime(item.createdAt),
style: const TextStyle(
color: AppColors.muted, fontSize: 11),
),
],
),
const SizedBox(height: 12),
],
if (item.description != null &&
item.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
item.description!,
style: const TextStyle(
fontSize: 13, color: AppColors.text),
),
),
const SizedBox(height: 12),
],
),
),
),
),
],
],
),
),
);
}
@ -394,21 +425,29 @@ class _ErrorPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off, size: 48, color: AppColors.muted),
const SizedBox(height: 12),
Text(message,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted)),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Coba lagi'),
),
],
child: Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
boxShadow: AppDecorations.cardShadow,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off, size: 48, color: AppColors.muted),
const SizedBox(height: 12),
Text(message,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted)),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Coba lagi'),
),
],
),
),
);
}
@ -421,21 +460,29 @@ class _EmptyPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.history, size: 64, color: AppColors.muted),
const SizedBox(height: 12),
Text(
filter == 'ALL'
? 'Belum ada aktivitas'
: 'Tidak ada aktivitas "$filter"',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.muted),
),
],
child: Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
boxShadow: AppDecorations.cardShadow,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.history, size: 64, color: AppColors.muted),
const SizedBox(height: 12),
Text(
filter == 'ALL'
? 'Belum ada aktivitas'
: 'Tidak ada aktivitas "$filter"',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.muted),
),
],
),
),
);
}

View File

@ -0,0 +1,3 @@
Activity log application layer.
This layer is reserved for Cubit/BLoC orchestration between the activity log UI and domain contracts.

View File

@ -0,0 +1,3 @@
Activity log data layer.
This layer is reserved for remote/local data sources and repository implementations.

View File

@ -0,0 +1,3 @@
Activity log domain layer.
This layer is reserved for entities, repository contracts, and use cases.

View File

@ -9,8 +9,11 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart';
import '../../core/ai/detection_export.dart';
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../core/services/tts_service.dart';
import '../../core/utils/operation_guard.dart';
import '../../shared/widgets/animations/animations.dart';
import '../../shared/widgets/feature_page.dart';
class AiBenchmarkScreen extends StatefulWidget {
@ -116,18 +119,22 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
CameraController? controller;
await guarded<void>(
() async {
final cameras =
await availableCameras().timeout(const Duration(seconds: 3));
if (cameras.isNotEmpty) {
final activeController = CameraController(
cameras.first,
ResolutionPreset.low,
enableAudio: false,
);
controller = activeController;
await activeController.initialize().timeout(const Duration(seconds: 5));
await activeController.takePicture().timeout(const Duration(seconds: 5));
}
final cameras =
await availableCameras().timeout(const Duration(seconds: 3));
if (cameras.isNotEmpty) {
final activeController = CameraController(
cameras.first,
ResolutionPreset.low,
enableAudio: false,
);
controller = activeController;
await activeController
.initialize()
.timeout(const Duration(seconds: 5));
await activeController
.takePicture()
.timeout(const Duration(seconds: 5));
}
},
onError: (_) {},
);
@ -198,7 +205,11 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
label: const Text('Clear log'),
),
const SizedBox(height: 16),
for (final run in _runs) _BenchmarkCard(run: run),
StaggerWrapper(
children: [
for (final run in _runs) _BenchmarkCard(run: run),
],
),
if (_runs.isEmpty)
const FeatureEmptyPanel(
icon: Icons.speed,
@ -224,9 +235,10 @@ class _BenchmarkCard extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -262,7 +274,8 @@ class _StatusBox extends StatelessWidget {
return DecoratedBox(
decoration: BoxDecoration(
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(14),
borderRadius: AppDecorations.cardRadius,
boxShadow: AppDecorations.cardShadow,
),
child: Padding(
padding: const EdgeInsets.all(12),
@ -279,17 +292,17 @@ class _StatusBox extends StatelessWidget {
Future<List<String>> _discoverTfliteModels() async {
return await guarded<List<String>>(
() async {
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
final models = manifest.keys
.where((key) =>
key.startsWith('assets/models/') && key.endsWith('.tflite'))
.toList()
..sort();
return models;
},
) ??
() async {
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
final models = manifest.keys
.where((key) =>
key.startsWith('assets/models/') && key.endsWith('.tflite'))
.toList()
..sort();
return models;
},
) ??
const [];
}

View File

@ -0,0 +1,3 @@
AI benchmark application layer.
This layer is reserved for benchmark Cubit/BLoC orchestration.

View File

@ -0,0 +1,3 @@
AI benchmark data layer.
This layer is reserved for benchmark result persistence and export adapters.

View File

@ -0,0 +1,3 @@
AI benchmark domain layer.
This layer is reserved for benchmark entities and use cases.

View File

@ -0,0 +1,3 @@
Auth application layer.
This layer is reserved for auth Cubit/BLoC orchestration between auth UI and auth domain contracts.

View File

@ -8,14 +8,20 @@ import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app/app_cubit.dart';
import '../../app/router.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/fcm_service.dart';
import '../../core/services/incoming_call_polling_service.dart';
import '../../core/services/offline_queue_service.dart';
import '../../core/services/tts_service.dart';
import '../../core/services/websocket_service.dart';
import '../../core/storage/secure_storage.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../core/theme/app_text_styles.dart';
// ---------------------------------------------------------------------------
// LoginScreen
@ -76,7 +82,8 @@ class _LoginScreenState extends State<LoginScreen> {
},
onError: (message) => _snack(context, message),
fallback: 'Login gagal. Periksa email dan password kamu.',
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
connectionHint:
'Tidak bisa ke server. Cek URL backend di Connect Server dan pastikan server aktif.',
);
if (mounted) setState(() => _loading = false);
}
@ -147,125 +154,152 @@ class _AuthFrame extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFEAF4FF),
body: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
),
backgroundColor: AppColors.softBlueBg,
body: LayoutBuilder(
builder: (context, constraints) {
final compact =
constraints.maxWidth < 480 || constraints.maxHeight < 720;
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.softBlueBg,
Colors.white,
AppColors.softPinkBg,
],
),
),
),
Positioned(
top: -90,
right: -60,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.85, end: 1),
duration: const Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
builder: (_, value, child) => Transform.scale(
scale: value,
child: child,
),
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
shape: BoxShape.circle,
),
),
),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 18).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset),
child: child,
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
compact ? 14 : 24,
compact ? 12 : 24,
compact ? 14 : 24,
20 + MediaQuery.of(context).viewInsets.bottom,
),
child: RepaintBoundary(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: Colors.white.withValues(alpha: 0.8)),
boxShadow: [
BoxShadow(
color:
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
blurRadius: 40,
offset: const Offset(0, 20),
),
],
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 380 : 430),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 18).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset),
child: child,
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
child: RepaintBoundary(
child: Container(
decoration: BoxDecoration(
color: AppColors.cardWhite,
borderRadius:
BorderRadius.circular(compact ? 22 : 28),
boxShadow: AppDecorations.cardShadow,
),
child: Padding(
padding: EdgeInsets.fromLTRB(
compact ? 18 : 24,
compact ? 18 : 26,
compact ? 18 : 24,
compact ? 18 : 24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF1D4ED8),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 30),
Row(
children: [
Container(
width: compact ? 44 : 56,
height: compact ? 44 : 56,
decoration: BoxDecoration(
gradient: AppDecorations.blueGradient,
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x334A90D9),
blurRadius: 18,
offset: Offset(0, 8),
),
],
),
child: Icon(Icons.navigation_rounded,
color: Colors.white,
size: compact ? 26 : 30),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textDark,
),
),
),
],
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFF0F172A),
SizedBox(height: compact ? 14 : 16),
if (!compact)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppColors.softBlueBg,
borderRadius: BorderRadius.circular(999),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.shield_outlined,
size: 14,
color: AppColors.primaryBlue),
SizedBox(width: 6),
Text(
'Secure Assistive Navigation',
style: TextStyle(
color: AppColors.primaryBlue,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
],
),
),
if (!compact) const SizedBox(height: 18),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.heading.copyWith(
fontSize: compact ? 26 : null,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
height: 1.35,
),
),
SizedBox(height: compact ? 18 : 26),
child,
],
),
const SizedBox(height: 22),
Text(
title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.35,
),
),
const SizedBox(height: 26),
child,
],
),
),
),
),
@ -273,8 +307,8 @@ class _AuthFrame extends StatelessWidget {
),
),
),
),
],
);
},
),
);
}
@ -311,9 +345,16 @@ Future<void> _saveAuthAndRoute(
void _startPostLoginServices(String serverUrl) {
Future.microtask(() async {
await sl<WebSocketService>()
.connect(serverUrl)
.timeout(const Duration(seconds: 2));
sl<IncomingCallPollingService>().start();
await sl<FcmService>().init().timeout(const Duration(seconds: 4));
final ws = sl<WebSocketService>();
await ws.connect(serverUrl).timeout(const Duration(seconds: 2));
ws.subscribeCall((data) {
final type = data['type']?.toString();
if (type == 'INCOMING_CALL') {
appRouter.go('/incoming-call', extra: data);
}
});
await sl<OfflineQueueService>()
.syncPending(sl<ApiClient>())
.timeout(const Duration(seconds: 3));

View File

@ -7,6 +7,10 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../core/theme/app_text_styles.dart';
import '../../shared/widgets/animations/animations.dart';
// ---------------------------------------------------------------------------
// RegisterScreen
@ -69,7 +73,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
},
onError: (message) => _snack(context, message),
fallback: 'Registrasi gagal. Periksa data akun kamu.',
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
connectionHint:
'Tidak bisa ke server. Cek URL backend di Connect Server dan pastikan server aktif.',
);
if (mounted) setState(() => _loading = false);
}
@ -128,7 +133,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _role == 'USER'
? const Color(0xFFEFF6FF)
? AppColors.softBlueBg
: const Color(0xFFF0FDF4),
borderRadius: BorderRadius.circular(999),
),
@ -234,18 +239,19 @@ class _RoleCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
return BounceTap(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: selected ? const Color(0xFFEFF6FF) : Colors.white,
borderRadius: BorderRadius.circular(14),
color: selected ? AppColors.softBlueBg : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: selected ? const Color(0xFF1A56DB) : const Color(0xFFE2E8F0),
color: selected ? AppColors.primaryBlue : AppColors.border,
width: selected ? 2 : 1,
),
boxShadow: selected ? AppDecorations.cardShadow : null,
),
child: Row(
children: [
@ -253,10 +259,9 @@ class _RoleCard extends StatelessWidget {
width: 48,
height: 48,
decoration: BoxDecoration(
color: selected
? const Color(0xFF1A56DB)
: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(12),
color:
selected ? AppColors.primaryBlue : const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(50),
),
child: Icon(icon,
color: selected ? Colors.white : const Color(0xFF64748B)),
@ -267,16 +272,16 @@ class _RoleCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
fontWeight: FontWeight.w800, fontSize: 16)),
style: AppTextStyles.subheading.copyWith(fontSize: 16)),
Text(subtitle,
style: const TextStyle(
color: Color(0xFF64748B), fontSize: 13)),
color: AppColors.muted, fontSize: 13)),
],
),
),
if (selected)
const Icon(Icons.check_circle_rounded, color: Color(0xFF1A56DB)),
const Icon(Icons.check_circle_rounded,
color: AppColors.primaryBlue),
],
),
),
@ -298,125 +303,125 @@ class _AuthFrame extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFEAF4FF),
body: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
),
backgroundColor: AppColors.softBlueBg,
body: LayoutBuilder(
builder: (context, constraints) {
final compact =
constraints.maxWidth < 480 || constraints.maxHeight < 720;
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.softBlueBg,
Colors.white,
AppColors.softPinkBg,
],
),
),
),
Positioned(
top: -90,
right: -60,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.85, end: 1),
duration: const Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
builder: (_, value, child) => Transform.scale(
scale: value,
child: child,
),
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
shape: BoxShape.circle,
),
),
),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 18).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset),
child: child,
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
compact ? 14 : 24,
compact ? 12 : 24,
compact ? 14 : 24,
20 + MediaQuery.of(context).viewInsets.bottom,
),
child: RepaintBoundary(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: Colors.white.withValues(alpha: 0.8)),
boxShadow: [
BoxShadow(
color:
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
blurRadius: 40,
offset: const Offset(0, 20),
),
],
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 380 : 430),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 18).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset),
child: child,
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
child: RepaintBoundary(
child: Container(
decoration: BoxDecoration(
color: AppColors.cardWhite,
borderRadius:
BorderRadius.circular(compact ? 22 : 28),
boxShadow: AppDecorations.cardShadow,
),
child: Padding(
padding: EdgeInsets.fromLTRB(
compact ? 18 : 24,
compact ? 18 : 26,
compact ? 18 : 24,
compact ? 18 : 24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF1D4ED8),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 30),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFF0F172A),
Row(
children: [
Container(
width: compact ? 44 : 56,
height: compact ? 44 : 56,
decoration: BoxDecoration(
gradient: AppDecorations.blueGradient,
borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x334A90D9),
blurRadius: 18,
offset: Offset(0, 8),
),
],
),
child: Icon(Icons.navigation_rounded,
color: Colors.white,
size: compact ? 26 : 30),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textDark,
),
),
),
],
),
SizedBox(height: compact ? 14 : 22),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.heading.copyWith(
fontSize: compact ? 26 : null,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
height: 1.35,
),
),
SizedBox(height: compact ? 18 : 26),
child,
],
),
const SizedBox(height: 22),
Text(
title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.35,
),
),
const SizedBox(height: 26),
child,
],
),
),
),
),
@ -424,8 +429,8 @@ class _AuthFrame extends StatelessWidget {
),
),
),
),
],
);
},
),
);
}

View File

@ -3,9 +3,17 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/router.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/fcm_service.dart';
import '../../core/services/incoming_call_polling_service.dart';
import '../../core/services/offline_queue_service.dart';
import '../../core/services/websocket_service.dart';
import '../../core/storage/secure_storage.dart';
import '../../shared/widgets/walkguide_loading_screen.dart';
// ---------------------------------------------------------------------------
// SplashScreen
@ -32,110 +40,101 @@ class SplashScreen extends StatefulWidget {
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _animCtrl;
late final Animation<double> _fadeAnim;
late final AnimationController _screenCtrl;
late final Animation<double> _screenFade;
@override
void initState() {
super.initState();
_animCtrl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 700));
_fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeIn);
_animCtrl.forward();
_screenCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
value: 1,
);
_screenFade = CurvedAnimation(
parent: _screenCtrl,
curve: Curves.easeOutCubic,
);
_route();
}
@override
void dispose() {
_animCtrl.dispose();
_screenCtrl.dispose();
super.dispose();
}
Future<void> _route() async {
final routed = await runFriendlyAction(
() async {
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 500));
// Animasi logo selalu tampil minimal 900ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 900));
final storage = sl<SecureStorage>();
final token =
await storage.getAccessToken().timeout(const Duration(seconds: 3));
final role =
await storage.getUserRole().timeout(const Duration(seconds: 3));
final token = await storage.getAccessToken().timeout(
const Duration(seconds: 3),
);
final role = await storage.getUserRole().timeout(
const Duration(seconds: 3),
);
if (!mounted) return;
if (token == null || role == null) {
context.go('/login');
await _fadeOutThenGo('/login');
return;
}
final serverUrl = await AppConstants.getServerUrl();
if (serverUrl != null && serverUrl.isNotEmpty) {
_startAutoLoginServices(serverUrl);
} else {
sl<IncomingCallPollingService>().start();
}
// Auto-login: arahkan ke home sesuai role.
context.go(role == 'ROLE_GUARDIAN'
? '/guardian/dashboard'
: '/user/walkguide');
await _fadeOutThenGo(
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide',
);
},
onError: (_) {},
fallback: 'Sesi belum bisa dipulihkan.',
);
if (!routed && mounted) context.go('/login');
if (!routed && mounted) await _fadeOutThenGo('/login');
}
Future<void> _fadeOutThenGo(String route) async {
if (!mounted) return;
await _screenCtrl.reverse();
if (mounted) context.go(route);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A56DB),
body: Center(
child: FadeTransition(
opacity: _fadeAnim,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo / icon
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(28),
),
child: const Icon(
Icons.navigation_rounded,
color: Colors.white,
size: 60,
),
),
const SizedBox(height: 24),
const Text(
'WalkGuide',
style: TextStyle(
color: Colors.white,
fontSize: 34,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
const SizedBox(height: 6),
const Text(
'AI-powered navigation for everyone',
style: TextStyle(
color: Colors.white70,
fontSize: 13,
),
),
const SizedBox(height: 48),
const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
),
],
),
),
return FadeTransition(
opacity: _screenFade,
child: const WalkGuideLoadingScreen(
subtitle: 'Restoring your session',
),
);
}
}
void _startAutoLoginServices(String serverUrl) {
Future.microtask(() async {
sl<IncomingCallPollingService>().start();
await sl<ApiClient>().init(serverUrl);
await sl<FcmService>().init().timeout(const Duration(seconds: 4));
final ws = sl<WebSocketService>();
await ws.connect(serverUrl).timeout(const Duration(seconds: 2));
ws.subscribeCall((data) {
if (data['type']?.toString() == 'INCOMING_CALL') {
appRouter.go('/incoming-call', extra: data);
}
});
await sl<OfflineQueueService>()
.syncPending(sl<ApiClient>())
.timeout(const Duration(seconds: 3));
}).catchError((Object e) {
debugPrint('Auto-login services skipped: $e');
});
}

View File

@ -0,0 +1,3 @@
Call application layer.
This layer owns call state orchestration. The current route keeps a compatibility screen while call side effects are delegated to core services.

View File

@ -0,0 +1,3 @@
Call data layer.
This layer is reserved for call remote data sources and repository implementations over `/shared/call/**`.

View File

@ -0,0 +1,3 @@
Call domain layer.
This layer is reserved for call session entities, repository contracts, and call use cases.

View File

@ -0,0 +1,3 @@
Guardian dashboard application layer.
This layer is reserved for dashboard, map, SOS, notification, AI config, shortcut, and geofence Cubits.

View File

@ -0,0 +1,3 @@
Guardian dashboard data layer.
This layer is reserved for `/guardian/**` data sources and repository implementations.

View File

@ -0,0 +1,3 @@
Guardian dashboard domain layer.
This layer is reserved for Guardian dashboard entities, repository contracts, and use cases.

View File

@ -8,6 +8,9 @@ import 'package:latlong2/latlong.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../shared/widgets/animations/animations.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianMapScreen extends StatefulWidget {
@ -106,8 +109,9 @@ class _GuardianMapCard extends StatelessWidget {
final center = _pointFrom(location) ??
(points.isNotEmpty ? points.first : null) ??
const LatLng(-7.2575, 112.7521);
return ClipRRect(
borderRadius: BorderRadius.circular(20),
return Container(
decoration: AppDecorations.card,
clipBehavior: Clip.antiAlias,
child: FlutterMap(
options: MapOptions(initialCenter: center, initialZoom: 16),
children: [
@ -121,7 +125,7 @@ class _GuardianMapCard extends StatelessWidget {
Polyline(
points: points,
strokeWidth: 4,
color: const Color(0xFF2563EB),
color: AppColors.primaryBlue,
),
],
),
@ -171,10 +175,18 @@ class _TimelineList extends StatelessWidget {
),
);
}
return ListView.separated(
itemCount: segments.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, index) => _TimelineCard(segment: segments[index]),
return ListView(
children: [
StaggerWrapper(
children: [
for (final segment in segments)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _TimelineCard(segment: segment),
),
],
),
],
);
}
}
@ -189,9 +201,10 @@ class _TimelineCard extends StatelessWidget {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
),
child: Row(
children: [
@ -199,10 +212,10 @@ class _TimelineCard extends StatelessWidget {
width: 42,
height: 42,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(14),
color: AppColors.softBlueBg,
borderRadius: BorderRadius.circular(50),
),
child: Icon(segment.icon, color: const Color(0xFF1D4ED8)),
child: Icon(segment.icon, color: AppColors.primaryBlue),
),
const SizedBox(width: 12),
Expanded(

View File

@ -8,6 +8,9 @@ import 'package:record/record.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../shared/widgets/animations/animations.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianSendNotifScreen extends StatefulWidget {
@ -132,132 +135,130 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
child: ListView(
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: const Color(0xFF1E293B).withValues(alpha: 0.06),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(
value: false,
icon: Icon(Icons.message_outlined),
label: Text('Text'),
FadeSlideWrapper(
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(
value: false,
icon: Icon(Icons.message_outlined),
label: Text('Text'),
),
ButtonSegment(
value: true,
icon: Icon(Icons.mic_none_outlined),
label: Text('Voice'),
),
],
selected: {_voiceMode},
onSelectionChanged: _loading || _recording
? null
: (value) => setState(() => _voiceMode = value.first),
),
const SizedBox(height: 14),
TextField(
controller: _message,
minLines: _voiceMode ? 2 : 5,
maxLines: _voiceMode ? 3 : 8,
decoration: const InputDecoration(
labelText: 'Message',
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
prefixIcon: Icon(Icons.message_outlined),
alignLabelWithHint: true,
),
ButtonSegment(
value: true,
icon: Icon(Icons.mic_none_outlined),
label: Text('Voice'),
),
if (_voiceMode) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
CircleAvatar(
backgroundColor: _recording
? const Color(0xFFFEE2E2)
: const Color(0xFFEFF6FF),
child: Icon(
_recording ? Icons.graphic_eq : Icons.mic,
color: _recording
? const Color(0xFFDC2626)
: const Color(0xFF2563EB),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_recording
? 'Recording... tap stop when done'
: _voicePath == null
? 'No voice note recorded'
: 'Voice note ready',
style: const TextStyle(
fontWeight: FontWeight.w800),
),
Text(
_recording
? 'Speak clearly near the microphone'
: _voicePath == null
? 'Record a short message for User'
: '${_voiceDuration}s audio attached',
style: const TextStyle(
color: Color(0xFF64748B), fontSize: 12),
),
],
),
),
FilledButton.icon(
onPressed: _loading ? null : _toggleRecording,
icon: Icon(_recording
? Icons.stop
: Icons.fiber_manual_record),
label: Text(_recording ? 'Stop' : 'Record'),
style: FilledButton.styleFrom(
backgroundColor: _recording
? const Color(0xFFDC2626)
: const Color(0xFF2563EB),
),
),
],
),
),
],
selected: {_voiceMode},
onSelectionChanged: _loading || _recording
? null
: (value) => setState(() => _voiceMode = value.first),
),
const SizedBox(height: 14),
TextField(
controller: _message,
minLines: _voiceMode ? 2 : 5,
maxLines: _voiceMode ? 3 : 8,
decoration: const InputDecoration(
labelText: 'Message',
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
prefixIcon: Icon(Icons.message_outlined),
alignLabelWithHint: true,
),
),
if (_voiceMode) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
CircleAvatar(
backgroundColor: _recording
? const Color(0xFFFEE2E2)
: const Color(0xFFEFF6FF),
child: Icon(
_recording ? Icons.graphic_eq : Icons.mic,
color: _recording
? const Color(0xFFDC2626)
: const Color(0xFF2563EB),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_recording
? 'Recording... tap stop when done'
: _voicePath == null
? 'No voice note recorded'
: 'Voice note ready',
style: const TextStyle(
fontWeight: FontWeight.w800),
),
Text(
_recording
? 'Speak clearly near the microphone'
: _voicePath == null
? 'Record a short message for User'
: '${_voiceDuration}s audio attached',
style: const TextStyle(
color: Color(0xFF64748B), fontSize: 12),
),
],
),
),
FilledButton.icon(
onPressed: _loading ? null : _toggleRecording,
icon: Icon(_recording ? Icons.stop : Icons.fiber_manual_record),
label: Text(_recording ? 'Stop' : 'Record'),
style: FilledButton.styleFrom(
backgroundColor: _recording
? const Color(0xFFDC2626)
: const Color(0xFF2563EB),
),
),
],
),
FilledButton.icon(
onPressed: _loading ? null : _send,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(_loading
? 'Sending...'
: _voiceMode
? 'Send Voice Message'
: 'Send Message'),
),
],
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _loading ? null : _send,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(_loading
? 'Sending...'
: _voiceMode
? 'Send Voice Message'
: 'Send Message'),
),
],
),
),
),
],

View File

@ -9,6 +9,9 @@ import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../shared/widgets/animations/animations.dart';
import '../../core/storage/secure_storage.dart';
import '../../shared/widgets/feature_page.dart';
@ -49,23 +52,27 @@ class GuardianSettingsScreen extends StatelessWidget {
subtitle: 'Account, pairing, AI tools, and server',
child: ListView(
children: [
_SettingsTile(
icon: Icons.link,
title: 'Pair User',
subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
onTap: () => context.go('/guardian/pairing'),
),
_SettingsTile(
icon: Icons.speed,
title: 'AI Benchmark',
subtitle: 'Catat capture, inference, notification, dan TTS.',
onTap: () => context.go('/guardian/benchmark'),
),
_SettingsTile(
icon: Icons.tune,
title: 'AI Config',
subtitle: 'Atur threshold deteksi dan label yang aktif.',
onTap: () => context.go('/guardian/ai-config'),
StaggerWrapper(
children: [
_SettingsTile(
icon: Icons.link,
title: 'Pair User',
subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
onTap: () => context.go('/guardian/pairing'),
),
_SettingsTile(
icon: Icons.speed,
title: 'AI Benchmark',
subtitle: 'Catat capture, inference, notification, dan TTS.',
onTap: () => context.go('/guardian/benchmark'),
),
_SettingsTile(
icon: Icons.tune,
title: 'AI Config',
subtitle: 'Atur threshold deteksi dan label yang aktif.',
onTap: () => context.go('/guardian/ai-config'),
),
],
),
const SizedBox(height: 18),
OutlinedButton.icon(
@ -103,19 +110,28 @@ class _SettingsTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: ListTile(
leading: Icon(icon, color: const Color(0xFF1D4ED8)),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
return BounceTap(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
),
child: ListTile(
leading: Container(
width: 44,
height: 44,
decoration: AppDecorations.iconCircle(),
child: Icon(icon, color: AppColors.primaryBlue),
),
title:
Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
),
),
);
}

View File

@ -158,8 +158,10 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(_labelFromKey(item['commandKey']?.toString() ?? '') ??
'Voice Command'),
title: Text(
_labelFromKey(item['commandKey']?.toString() ?? '') ??
'Voice Command',
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -180,11 +182,13 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save')),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save'),
),
],
),
),
@ -209,8 +213,9 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(_labelFromKey(item['shortcutKey']?.toString() ?? '') ??
'Shortcut'),
title: Text(
_labelFromKey(item['shortcutKey']?.toString() ?? '') ?? 'Shortcut',
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -230,6 +235,31 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
prefixIcon: Icon(Icons.numbers_outlined),
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ActionChip(
avatar: const Icon(Icons.volume_up_outlined, size: 18),
label: const Text('Volume Up'),
onPressed: () {
buttonName.text = 'Volume Up';
buttonCode.text = '24';
setDialogState(() {});
},
),
ActionChip(
avatar: const Icon(Icons.volume_down_outlined, size: 18),
label: const Text('Volume Down'),
onPressed: () {
buttonName.text = 'Volume Down';
buttonCode.text = '25';
setDialogState(() {});
},
),
],
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: enabled,
@ -240,11 +270,13 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save')),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save'),
),
],
),
),
@ -273,8 +305,9 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
},
onError: (message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
},
fallback: 'Konfigurasi belum bisa disimpan.',
);
@ -301,18 +334,15 @@ class _EndpointCard extends StatelessWidget {
'',
) ??
'Item #${item['id'] ?? '-'}';
final subtitle = _firstText(
item,
[
'triggerPhrase',
'buttonName',
'description',
'action',
'shortcut',
'status',
'createdAt'
],
) ??
final subtitle = _firstText(item, [
'triggerPhrase',
'buttonName',
'description',
'action',
'shortcut',
'status',
'createdAt',
]) ??
'Data aktif';
final enabled = item['enabled'] != false;
return Container(
@ -338,11 +368,15 @@ class _EndpointCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(fontWeight: FontWeight.w800)),
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(subtitle,
style: const TextStyle(color: Color(0xFF64748B))),
Text(
subtitle,
style: const TextStyle(color: Color(0xFF64748B)),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
@ -415,7 +449,6 @@ String? _labelFromKey(String value) {
return value
.split('_')
.where((part) => part.isNotEmpty)
.map((part) =>
part[0].toUpperCase() + part.substring(1).toLowerCase())
.map((part) => part[0].toUpperCase() + part.substring(1).toLowerCase())
.join(' ');
}

View File

@ -0,0 +1,3 @@
Home application layer.
This layer is reserved for role-specific home state orchestration.

View File

@ -0,0 +1,3 @@
Home data layer.
This layer is reserved for home/dashboard data adapters.

View File

@ -0,0 +1,3 @@
Home domain layer.
This layer is reserved for home/dashboard domain entities and use cases.

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_text_styles.dart';
class HomeScreen extends StatelessWidget {
final String role;
@ -7,14 +10,26 @@ class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dashboard Walk Guide')),
body: Center(
child: Text(
role == 'ROLE_ADMIN' ? 'Selamat Datang Admin!' : 'Mode Walk Guide Siap!',
style: const TextStyle(fontSize: 24),
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.softBlueBg, Colors.white],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
role == 'ROLE_ADMIN'
? 'Selamat Datang Admin!'
: 'Mode Walk Guide Siap!',
textAlign: TextAlign.center,
style: AppTextStyles.heading,
),
),
),
);
}
}
}

View File

@ -3,7 +3,6 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:camera/camera.dart';
import '../../../core/secure_storage.dart';
import '../../auth/presentation/login_screen.dart';
import '../../../../main.dart';
class UserDashboardScreen extends StatefulWidget {
const UserDashboardScreen({super.key});
@ -12,7 +11,8 @@ class UserDashboardScreen extends StatefulWidget {
State<UserDashboardScreen> createState() => _UserDashboardScreenState();
}
class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerProviderStateMixin {
class _UserDashboardScreenState extends State<UserDashboardScreen>
with TickerProviderStateMixin {
CameraController? _camCtrl;
late AnimationController _radarCtrl;
late Animation<double> _radarAnim;
@ -31,8 +31,10 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
}
Future<void> _initCamera() async {
final cameras = await availableCameras();
if (cameras.isEmpty) return;
_camCtrl = CameraController(cameras[0], ResolutionPreset.high, enableAudio: false);
_camCtrl = CameraController(cameras[0], ResolutionPreset.medium,
enableAudio: false);
await _camCtrl!.initialize();
if (mounted) setState(() {});
}
@ -85,7 +87,8 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
gradient: RadialGradient(
colors: [
Colors.transparent,
const Color(0xFF10B981).withValues(alpha: 0.05 + _radarCtrl.value * 0.08),
const Color(0xFF10B981)
.withValues(alpha: 0.05 + _radarCtrl.value * 0.08),
],
stops: const [0.5, 1.0],
radius: 1.4,
@ -127,7 +130,8 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
),
child: Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(20),
@ -158,7 +162,8 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
const Spacer(),
IconButton(
onPressed: _logout,
icon: const Icon(Icons.power_settings_new, color: Colors.white, size: 26),
icon: const Icon(Icons.power_settings_new,
color: Colors.white, size: 26),
style: IconButton.styleFrom(
backgroundColor: Colors.redAccent.withValues(alpha: 0.8),
),
@ -204,15 +209,19 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
color: const Color(0x33F59E0B),
borderRadius: BorderRadius.circular(7),
),
child: const Icon(Icons.warning_amber_rounded, color: Color(0xFFF59E0B), size: 16),
child: const Icon(Icons.warning_amber_rounded,
color: Color(0xFFF59E0B), size: 16),
),
const SizedBox(width: 10),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Obstacle ahead',
style: GoogleFonts.inter(
color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500)),
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500)),
Text('2.1m — Haptic alert sent',
style: GoogleFonts.inter(color: Colors.white60, fontSize: 11)),
style:
GoogleFonts.inter(color: Colors.white60, fontSize: 11)),
]),
]),
),
@ -234,9 +243,12 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
),
child: Column(children: [
Row(children: [
Expanded(child: _bigBtn(const Color(0xCC1A56DB), Icons.mic, () {})),
Expanded(
child: _bigBtn(const Color(0xCC1A56DB), Icons.mic, () {})),
const SizedBox(width: 12),
Expanded(child: _bigBtn(const Color(0xCCDC2626), Icons.phone_in_talk, () {})),
Expanded(
child: _bigBtn(
const Color(0xCCDC2626), Icons.phone_in_talk, () {})),
]),
const SizedBox(height: 8),
Text(
@ -290,7 +302,8 @@ class _RadarPainter extends CustomPainter {
..style = PaintingStyle.stroke
..strokeWidth = 1.2;
for (final r in [48.0, 34.0, 20.0]) {
paint.color = const Color(0xFF60EFAB).withValues(alpha: 0.15 + (50 - r) / 100);
paint.color =
const Color(0xFF60EFAB).withValues(alpha: 0.15 + (50 - r) / 100);
canvas.drawCircle(center, r, paint);
}
paint
@ -301,4 +314,4 @@ class _RadarPainter extends CustomPainter {
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
}

View File

@ -0,0 +1,3 @@
Manual application layer.
This layer is reserved for manual/TTS instruction state orchestration.

View File

@ -0,0 +1,3 @@
Manual data layer.
This layer is reserved for local command and shortcut documentation data sources.

View File

@ -0,0 +1,3 @@
Manual domain layer.
This layer is reserved for manual section entities and instruction use cases.

View File

@ -1,6 +1,10 @@
import 'package:flutter/material.dart';
import '../../core/services/voice_command_handler.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../shared/widgets/animations/animations.dart';
import '../../shared/widgets/feature_page.dart';
class ManualScreen extends StatelessWidget {
const ManualScreen({super.key});
@ -8,16 +12,38 @@ class ManualScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final commands = VoiceCommandKey.values.map((key) => key.name).toList();
return Scaffold(
appBar: AppBar(title: const Text('Manual')),
body: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: commands.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) => ListTile(
leading: const Icon(Icons.record_voice_over),
title: Text(commands[index]),
),
return FeaturePage(
title: 'Manual',
subtitle: 'Voice command yang tersedia',
child: ListView(
children: [
StaggerWrapper(
children: [
for (final command in commands)
Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
),
child: ListTile(
leading: Container(
width: 44,
height: 44,
decoration: AppDecorations.iconCircle(),
child: const Icon(
Icons.record_voice_over,
color: AppColors.primaryBlue,
),
),
title: Text(command),
),
),
],
),
],
),
);
}

View File

@ -0,0 +1,3 @@
Manual presentation layer.
This layer is reserved for manual pages and widgets.

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