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
*.env *.env
walkguide-backend/demo/secrets.properties
walkguide-backend/demo/hs_err_pid*.log 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) # Android SDK path (generated by Android Studio)
walkguide-mobile/walkguide_app/android/local.properties 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 # WalkGuide: AI-Powered Navigation
**Integrated Mobile Application Project** Integrated Mobile Application Project
*Flutter Mobile Frontend × Spring Boot Backend × OOAD* 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 | | Name | NIM | Responsibility |
|------|-----|---------------| |---|---:|---|
| Bambang Herlambang | 5803024019 | - | | Bambang Herlambang | 5803024019 | Mobile feature support, documentation, testing |
| Jap Robertus | 5803024004 | - | | Jap Robertus | 5803024004 | Mobile feature support, documentation, testing |
| Evan William | 5803024001 | Backend Engineer (Spring Boot API & Flutter) | | 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/) ## Project Status
[![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)
![Last Updated](https://img.shields.io/badge/Last_Updated-WIP-blue?style=for-the-badge) This repository is no longer a backend-only skeleton. The current codebase contains:
![Author](https://img.shields.io/badge/By-evan--william-brightgreen?style=for-the-badge)
[**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. - Register/Login with Guardian and User roles.
- Pairing by generated pairing code / user identity flow.
**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. - 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 ## 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 ## Tech Stack
### Backend (Spring Boot) ### Backend
| Library / Tool | Version | Purpose | | Tool / Library | Current Codebase | Purpose |
|---|---|---| |---|---|---|
| Java | 21 | Primary language | | Java | 21 | Backend language |
| Spring Boot | 3.3.x | Main framework | | Spring Boot | 3.2.5 | Main backend framework |
| Spring Security | (bundled) | Auth + RBAC | | Spring Security | Spring Boot managed | JWT auth and RBAC |
| Spring Data JPA | (bundled) | ORM | | Spring Data JPA | Spring Boot managed | ORM and repositories |
| Spring WebSocket (STOMP) | (bundled) | Real-time location & notification push | | Spring WebSocket | Spring Boot managed | STOMP realtime channels |
| PostgreSQL Driver | (bundled) | DB connection — university server `202.46.28.160` | | PostgreSQL Driver | Runtime dependency | University PostgreSQL database |
| Flyway | 10.x | Database schema migration | | Flyway | Core + PostgreSQL module | Database migrations |
| JJWT | 0.11.5 | JWT access + refresh token | | JJWT | 0.11.5 | JWT access token handling |
| Firebase Admin SDK | 9.x | FCM push notifications | | Springdoc OpenAPI | 2.3.0 | Swagger/OpenAPI docs |
| Agora RESTful API | - | Generate Agora RTC token for VoIP | | Lombok | 1.18.36 | Builders and boilerplate reduction |
| Springdoc OpenAPI | 2.3.0 | Swagger UI documentation | | JUnit 5 / Mockito / MockMvc | Test dependencies | Unit and controller testing |
| Lombok | latest | Boilerplate reduction | | Testcontainers | 1.19.7 | PostgreSQL-backed integration tests |
| JUnit 5 + Mockito | (bundled) | Unit testing | | JaCoCo | 0.8.11 | Coverage report |
| MockMvc + Testcontainers | 1.19.x | Integration testing with real PostgreSQL |
| JaCoCo | 0.8.x | Code coverage (target ≥ 70%) |
### Flutter (Mobile) ### Flutter
| Package | Version | Purpose | | Package | Current Codebase | Purpose |
|---|---|---| |---|---|---|
| flutter_bloc | 8.x | State management (sole pattern) | | flutter_bloc | 8.1.6 | Cubit/BLoC state management |
| go_router | 14.x | Navigation + role-based route guards | | go_router | 14.2.7 | App routing |
| dio | 5.x | HTTP client with interceptors | | dio | 5.4.3+1 | REST client |
| shared_preferences | 2.x | Persist dynamic server URL | | flutter_secure_storage | 9.2.2 | Secure token storage on mobile |
| flutter_secure_storage | 10.x | Secure JWT token storage | | shared_preferences | 2.3.2 | Server URL and web cache fallback |
| drift | 2.x | SQLite ORM for offline cache | | drift / sqlite3 | drift 2.18.0, sqlite3 2.4.7 | Local persistence support |
| tflite_flutter | 0.10.x | Run YOLOv8n on-device | | sqlite3_flutter_libs | 0.5.24 | Android/iOS SQLite native libs |
| camera | 0.10.x | Camera feed for YOLO inference | | camera | 0.11.0+2 | Camera feed |
| flutter_tts | 4.x | Text-to-Speech (ID + EN) | | tflite_flutter | 0.12.1 | On-device model inference |
| speech_to_text | 6.x | Always-listening voice commands | | flutter_tts | 4.0.2 | Text-to-speech |
| agora_rtc_engine | 6.x | VoIP call Guardian ↔ User | | speech_to_text | 7.0.0 | Voice recognition support |
| firebase_messaging | 14.x | FCM push notification receiver | | firebase_core / firebase_messaging | 3.3.0 / 15.1.0 | FCM integration |
| flutter_map | 6.x | OpenStreetMap (free, no API key) | | flutter_local_notifications | 17.2.1+2 | Foreground/local notification UI |
| geolocator | 11.x | Real-time GPS | | flutter_map / latlong2 | 7.0.2 / 0.9.1 | OpenStreetMap UI |
| stomp_dart_client | 2.x | WebSocket STOMP for live tracking | | geolocator | 12.0.0 | GPS/location |
| get_it | 7.x | Service locator / dependency injection | | agora_rtc_engine | 6.3.2 | VoIP engine integration |
| dartz | 0.10.x | `Either<Failure, Data>` typed error handling | | just_audio / record | 0.9.40 / 5.1.2 | Voice note playback/recording |
| vibration | 1.x | Haptic feedback on obstacle detection | | 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 | ### Backend
|---|---|---|
| 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) |
--- 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 ## Implementations
### Design A — User Mode (Visually Impaired) ### User Mode
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.
### Design B — Guardian Mode (Admin/Caregiver) - WalkGuide camera screen and AI pipeline integration points.
A command center dashboard for oversight and remote configuration. - TTS feedback and friendly error handling.
- **Real-time Live Map:** Tracks User's GPS location via WebSocket STOMP subscription, rendered on OpenStreetMap with `flutter_map`. Includes geofence circle overlay. - SOS screen with emergency action flow.
- **Remote AI Configuration:** Adjusts YOLO confidence threshold, alert distance thresholds, max inference FPS, and enabled object labels. - Notifications screen with read/read-all actions.
- **Voice Command Management:** Edits trigger phrases and enables/disables any of the 14 voice commands remotely. - Activity log, navigation, settings, pairing, and manual screens.
- **Notifications:** Sends text messages or voice notes (recorded audio) directly to the User's device. - Voice command and shortcut configuration retrieval paths.
- **Geofence:** Sets a geographic boundary — backend notifies Guardian via FCM when User exits. - 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 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 | | V1 | `V1__create_users_table.sql` | Present |
| V2 | Seed data | ✅ Exists | | V2 | `V2__seed_users.sql` | Present |
| V3 | Guardian-User link | ✅ Exists | | V3 | `V3__link_guardian_user.sql` | Present |
| V4 | Add `unique_user_id` column | 🔄 Needs creation | | V4 | `V4__alter_users_add_columns.sql` | Present |
| V5 | `pairing_relations` | 🔄 Needs creation | | V5 | `V5__create_pairing_relations.sql` | Present |
| V6 | `activity_logs` | 🔄 Needs creation | | V6 | `V6__create_activity_logs.sql` | Present |
| V7 | `obstacle_logs` | 🔄 Needs creation | | V7 | `V7__create_obstacle_logs.sql` | Present |
| V8 | `location_history` | 🔄 Needs creation | | V8 | `V8__create_location_history.sql` | Present |
| V9 | `guardian_notifications` | 🔄 Needs creation | | V9 | `V9__create_guardian_notifications.sql` | Present |
| V10 | `sos_events` | 🔄 Needs creation | | V10 | `V10__create_sos_events.sql` | Present |
| V11 | `user_settings` | 🔄 Needs creation | | V11 | `V11__create_user_settings.sql` | Present |
| V12 | `ai_configs` | 🔄 Needs creation | | V12 | `V12__create_ai_configs.sql` | Present |
| V13 | `voice_command_configs` | 🔄 Needs creation | | V13 | `V13__create_voice_command_configs.sql` | Present |
| V14 | `hardware_shortcuts` | 🔄 Needs creation | | V14 | `V14__create_hardware_shortcuts.sql` | Present |
| V15 | `geofence_configs` | 🔄 Needs creation | | V15 | `V15__create_geofence_configs.sql` | Present |
| V16 | `refresh_tokens` | 🔄 Needs creation | | V16 | `V16__create_refresh_tokens.sql` | Present |
| V17 | `V17__add_expiring_pairing_codes.sql` | Present |
---
## API Endpoints ## API Endpoints
26 REST endpoints across 5 groups (exam minimum: 10). ### Auth - `/api/v1/auth`
**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`
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| GET | `/dashboard` | Combined home data (user status, recent logs, SOS count) | | GET | `/ping` | Server connection check |
| GET | `/user-status` | Full status of paired User | | POST | `/register` | Register Guardian or User |
| GET | `/user-location` | Last known GPS location | | 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 | `/location-history` | Paginated location history |
| GET | `/activity-logs` | Paginated activity logs of paired User | | GET | `/activity-logs` | Paginated User activity logs |
| GET | `/obstacle-logs` | Paginated obstacle detection logs | | GET | `/obstacle-logs` | Paginated obstacle logs |
| POST | `/notifications/send` | Send text or voice note to User | | POST | `/notifications/send` | Send text or voice note notification |
| GET | `/sos-events` | Paginated SOS events | | GET | `/sos-events` | Paginated SOS events |
| PUT | `/sos/{id}/acknowledge` | Acknowledge SOS alert | | PUT | `/sos/{id}/acknowledge` | Mark SOS as acknowledged |
| GET/PUT | `/ai-config` | Get or update AI configuration | | PUT | `/sos/{id}/resolve` | Mark SOS as resolved/handled |
| GET/PUT | `/voice-commands` | Get or update voice command configs | | GET/PUT | `/ai-config` | Get/update AI config |
| GET/PUT | `/shortcuts` | Get or update hardware shortcut configs | | GET/PUT | `/voice-commands` | Get/update voice command config |
| GET/PUT | `/geofence` | Get or update geofence 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 | | Method | Path | Description |
|---|---|---| |---|---|---|
| GET | `/profile` | User profile (id, email, displayName, uniqueUserId) | | GET | `/profile` | Current User profile |
| GET/PUT | `/settings` | Get or update TTS/haptic settings | | GET/PUT | `/settings` | Get/update User settings |
| GET | `/voice-commands` | Get all voice commands (read-only) | | GET | `/voice-commands` | Get voice command config |
| GET/PUT | `/shortcuts` | Get or capture hardware shortcut assignments | | GET/PUT | `/shortcuts` | Get/update shortcut config |
| GET | `/ai-config` | Get AI config (read-only) | | GET | `/ai-config` | Get AI config |
| POST | `/location` | Send GPS update | | POST | `/location` | Send location update |
| POST | `/obstacle` | Log detected obstacle | | POST | `/obstacle` | Log obstacle |
| POST | `/sos` | Trigger SOS alert | | POST | `/sos` | Trigger SOS |
| GET | `/activity-logs` | Paginated own activity log | | GET | `/sos-events` | Get own SOS events |
| GET | `/notifications` | Paginated notifications from Guardian | | GET | `/activity-logs` | Get activity logs |
| GET | `/notifications/unread-count` | Unread notification count | | GET | `/notifications` | Get notifications |
| PUT | `/notifications/mark-all-read` | Mark all as read | | GET | `/notifications/unread-count` | Get unread count |
| PUT | `/notifications/{id}/read` | Mark one as read | | PUT | `/notifications/mark-all-read` | Mark all notifications read |
| POST | `/walkguide/start` | Log WalkGuide session start | | PUT | `/notifications/{id}/read` | Mark one notification read |
| POST | `/walkguide/stop` | Log WalkGuide session stop | | 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 | | Method | Path | Description |
|---|---|---| |---|---|---|
| POST | `/token` | Generate Agora RTC token | | POST | `/token` | Generate call token/channel payload |
| POST | `/notify` | Send "Incoming Call" FCM to other party | | POST | `/notify` | Notify other party of incoming call |
| POST | `/end` | Notify/end call session |
---
## Design Patterns ## 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 | | 1 | Creational | Builder | Backend entities/DTO construction, Lombok builders |
| 2 | Creational | **Singleton** | Flutter: `TtsService`, `YoloDetector`, `WebSocketService`, `AgoraService` via GetIt | | 2 | Creational | Singleton | Flutter services registered through GetIt |
| 3 | Structural | **Facade** | Flutter: `VoiceCommandHandler`; Backend: `GuardianDashboardService` | | 3 | Structural | Facade | Guardian dashboard aggregation and voice command/service coordination |
| 4 | Structural | **Repository (Proxy)** | All `*_repository_impl.dart` — proxy between domain and data sources | | 4 | Structural | Repository / Proxy | Spring Data repositories and Flutter repository-style data access |
| 5 | Behavioral | **Observer** | BLoC pattern (BLoC = Subject, Widgets = Observers); WebSocket callbacks | | 5 | Behavioral | Observer | BLoC/Cubit state listeners and WebSocket callbacks |
| 6 | Behavioral | **Strategy** | `ObstacleAnalyzer` direction strategy; `ObstacleAlertStrategyService` (backend) | | 6 | Behavioral | Strategy | Obstacle analysis / alert behavior mapping documented in OOAD |
| 7 | Behavioral | **Chain of Responsibility** | Spring Security filter chain; Dio interceptor chain | | 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 | ## Metrics And Evidence
|---|---|---|
| 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 |
--- 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 ## 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 ```text
/ /
├── walkguide-backend/ # [Spring Boot Engine — ACTIVE] |-- walkguide-backend/
│ ├── src/main/java/com/walkguide/ | |-- demo/
│ │ ├── config/ # SecurityConfig, WebSocketConfig, OpenApiConfig, FcmConfig, DataSeeder | |-- src/main/java/com/walkguide/
│ │ ├── enums/ # UserRole, PairingStatus, ActivityLogType, VoiceCommandKey, | | |-- config/
│ │ │ # HardwareShortcutKey, NotificationType, SosStatus | | |-- controller/
│ │ ├── entity/ # User, PairingRelation, ActivityLog, ObstacleLog, | | |-- dto/
│ │ │ # LocationHistory, GuardianNotification, SosEvent, | | |-- entity/
│ │ │ # UserSettings, AiConfig, VoiceCommandConfig, | | |-- enums/
│ │ │ # HardwareShortcut, GeofenceConfig, RefreshToken | | |-- exception/
│ │ ├── repository/ # JPA repositories for all entities | | |-- repository/
│ │ ├── dto/ | | |-- security/
│ │ │ ├── request/ # RegisterRequest, LoginRequest, InviteUserRequest, | | |-- service/
│ │ │ │ # LocationUpdateRequest, ObstacleLogRequest, | | `-- websocket/
│ │ │ │ # SendNotificationRequest, SosRequest, | |-- src/main/resources/
│ │ │ │ # AiConfigUpdateRequest, VoiceCommandUpdateRequest, | | |-- application.properties
│ │ │ │ # HardwareShortcutUpdateRequest, GeofenceConfigRequest, | | |-- application-dev.yml
│ │ │ │ # UserSettingsUpdateRequest, and more | | |-- application-prod.yml
│ │ │ └── response/ # ApiResponse (standard wrapper), AuthDataResponse, | | `-- db/migration/V1...V17
│ │ │ # UserProfileResponse, PairingStatusResponse, | |-- src/test/java/com/walkguide/
│ │ │ # ActivityLogResponse, ObstacleLogResponse, | |-- k6-tests/
│ │ │ # LocationResponse, NotificationResponse, | `-- pom.xml
│ │ │ # SosEventResponse, AgoraTokenResponse, |
│ │ │ # AiConfigResponse, VoiceCommandResponse, |-- walkguide-mobile/
│ │ │ # HardwareShortcutResponse, GeofenceResponse | `-- walkguide_app/
│ │ ├── service/ # AuthService, PairingService, ActivityLogService, | |-- lib/
│ │ │ # LocationService, ObstacleLogService, | | |-- app/
│ │ │ # NotificationService, SosService, | | |-- core/
│ │ │ # AiConfigService, VoiceCommandService, | | |-- features/
│ │ │ # HardwareShortcutService, GeofenceService, | | `-- shared/
│ │ │ # UserSettingsService, FcmService, | |-- assets/
│ │ │ # AgoraTokenService, GuardianDashboardService | | |-- images/
│ │ ├── controller/ # AuthController, PairingController, | | `-- models/
│ │ │ # GuardianController, UserController, CallController | |-- test/
│ │ ├── security/ # JwtUtil, JwtAuthFilter, CustomUserDetailsService | |-- integration_test/
│ │ ├── websocket/ # LocationBroadcaster (STOMP broadcaster) | `-- pubspec.yaml
│ │ └── exception/ # GlobalExceptionHandler, ResourceNotFoundException, |
│ │ # UnauthorizedException, PairingException |-- ooad-docs/
│ ├── src/main/resources/ | |-- 01_Builder_Pattern.puml
│ │ ├── application.properties # DB config (PostgreSQL 202.46.28.160:2002), Flyway, JWT | |-- 02_Singleton_Pattern.puml
│ │ ├── firebase/ | |-- 03_Facade_Pattern.puml
│ │ │ └── google-services-admin.json # FCM service account key (gitignored!) | |-- 04_Repository_Proxy_Pattern.puml
│ │ └── db/migration/ | |-- 05_Observer_Pattern.puml
│ │ ├── V1__create_users_table.sql ✅ Exists | |-- 06_Strategy_Pattern.puml
│ │ ├── V2__seed_users.sql ✅ Exists | |-- 07_ChainOfResponsibility_Pattern.puml
│ │ ├── V3__link_guardian_user.sql ✅ Exists | `-- diagrams/
│ │ ├── V4__add_unique_user_id.sql 🔄 Needs creation |
│ │ ├── V5__create_pairing_relations.sql 🔄 Needs creation |-- FULL_FLOW_ARCHITECTURE.md
│ │ ├── V6__create_activity_logs.sql 🔄 Needs creation |-- FINAL_EXAM_GUIDE.md
│ │ ├── V7__create_obstacle_logs.sql 🔄 Needs creation |-- TODO.md
│ │ ├── V8__create_location_history.sql 🔄 Needs creation `-- README.md
│ │ ├── 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
``` ```
--- ## Feature Flows
## Feature Flows (High-Level) ### Flow 1 - Register And Pairing
### Flow 1 — Register & Pairing 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.
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.
### Flow 2 — WalkGuide Active Detection ### Flow 2 - WalkGuide 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 3 — VoIP Call 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.
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.
### Flow 4 — Guardian Sends Notification → User Hears via TTS ### Flow 3 - SOS Alert
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 5 — SOS Alert 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.
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."
### Flow 6 — Geofence ### Flow 4 - Guardian Notification
Guardian sets center + radius on map → User's location updates checked against Haversine distance → on exit: FCM to Guardian + `GEOFENCE_EXIT` activity log.
--- 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 User sends location updates -> backend stores location history -> Guardian map/dashboard reads the latest location and history -> WebSocket support exists for real-time updates.
# 1. Clone the repository
git clone https://github.com/YourGroup/walkguide-final-exam.git
# 2. Run Backend (Spring Boot) ### Flow 6 - Call
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
# 3. Run Mobile (Flutter) - Connect to local or university backend Caller requests token/channel -> backend returns call token payload -> caller notifies target -> target receives incoming call flow -> call can be ended through shared endpoint.
cd ../walkguide-mobile
## 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 pub get
flutter run 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 ## 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 | | Metric | Evidence Location / Tool | Current README Status |
|---|---|---|---| |---|---|---|
| Cold Start Time | *Pending* | *Pending* | ⏳ | | Backend tests | Maven/JUnit output | To be generated |
| Memory Leak (10 Navs) | *Pending* | *Pending* | ⏳ | | Backend coverage | JaCoCo report | To be generated |
| API Error Rate | *Pending* | *Pending* | ⏳ | | Backend load | k6 results | Assets present, final run needed |
| YOLO Inference Latency (ms) | *Pending* | *Pending* | ⏳ | | Flutter tests | `flutter test` / integration tests | To be generated |
| API p95 Latency | *Pending* | *Pending* | ⏳ | | Flutter performance | Physical Android profile evidence | To be generated |
| JaCoCo Coverage | *Pending* | *Pending* | ⏳ | | APK size | `flutter build apk --analyze-size` | To be generated |
---
## Weekly Progress ## Weekly Progress
| Week | Target | Status | | Week | Target | Current Status |
|---|---|---| |---|---|---|
| 1 | Topic proposal, Use Case definitions, Repo setup | ✅ Done | | 1 | Topic proposal, use case definitions, repo setup | Done |
| 23 | OOAD diagrams, OpenAPI YAML drafted | 🔄 In Progress | | 2-3 | OOAD diagrams and traceability | Done, docs present in `ooad-docs/` |
| 4 | Spring Boot: Auth, Pairing, Entity, Migration V4V16 | 🔄 In Progress | | 4 | Spring Boot auth, pairing, entities, migrations | Implemented |
| 5 | Spring Boot: Location, SOS, Notification, WS, FCM, Agora | ⏳ Pending | | 5 | Location, SOS, notification, WebSocket, FCM, call support | Implemented with demo/service integrations |
| 6 | Spring Boot: Unit + Integration tests, JaCoCo ≥ 70% | ⏳ Pending | | 6 | Backend unit/integration testing and coverage setup | Implemented, final run evidence needed |
| 7 | Flutter: ServerConnect, Auth, WalkGuide + YOLO pipeline | ⏳ Pending | | 7 | Flutter server connect, auth, WalkGuide/YOLO support | Implemented |
| 8 | Flutter: Guardian dashboard, Call, SOS, Notifications | ⏳ Pending | | 8 | Guardian dashboard, SOS, notifications, voice notes, settings | Implemented |
| 9 | Feature freeze, integration testing, benchmark on device | ⏳ Pending | | 9 | Integration testing and benchmark evidence | Needs final evidence run |
| 10 | Final benchmarks, Report writing, Demo Video | ⏳ Pending | | 10 | Report, demo video, final submission polish | In progress |
---
## Core Objectives ## 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 ## License
Distributed under the [MIT License](LICENSE). Distributed under the [MIT License](LICENSE).
--- Final Exam: Integrated Mobile Application Project
Flutter x Spring Boot x Object-Oriented Analysis and Design
*Final Exam: Integrated Mobile Application Project* <br>
*Flutter × Spring Boot × 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 { class "VoiceCommandHandler\n<<Facade>>" as VoiceCommandHandler {
- _ttsService : TtsService - _ttsService : TtsService
- _sttService : SttService - _sttService : SttService
- _router : GoRouter - _router : CommandRouter
- _walkGuideBloc : WalkGuideBloc - _actions : Map<VoiceCommandKey, CommandAction>
- _sosBloc : SosBloc
- _notifBloc : NotificationBloc
+ processText(String command) : void + processText(String command) : void
- _matchCommand(String) : VoiceCommandKey? - _matchCommand(String) : VoiceCommandKey?
- _executeCommand(VoiceCommandKey) : void - _executeCommand(VoiceCommandKey) : void
} }
class "WalkGuideBloc\n<<Client>>" as WalkGuideBlocFacade { class "WalkGuideCubit\n<<Client>>" as WalkGuideCubitFacade {
+ onVoiceCommand(String text) + onVoiceCommand(String text)
} }
@ -69,8 +67,8 @@ package "③ Facade Pattern [Structural]" #FFF8E1 {
class "SttService " as SttServiceFacade <<Subsystem>> class "SttService " as SttServiceFacade <<Subsystem>>
class "TtsService " as TtsServiceFacade <<Subsystem>> class "TtsService " as TtsServiceFacade <<Subsystem>>
class "GoRouter\n<<Router>>" as GoRouterFacade <<Subsystem>> class "CommandRouter\n<<Router Adapter>>" as GoRouterFacade <<Subsystem>>
class "SosBloc " as SosBlocFacade <<Subsystem>> class "CommandAction\n<<Cubit Callback>>" as CommandActionFacade <<Subsystem>>
class "LocationService\n<<Service>>" as LocationService <<Subsystem>> class "LocationService\n<<Service>>" as LocationService <<Subsystem>>
class "ActivityLogService\n<<Service>>" as ActivityService <<Subsystem>> class "ActivityLogService\n<<Service>>" as ActivityService <<Subsystem>>
@ -82,11 +80,11 @@ package "③ Facade Pattern [Structural]" #FFF8E1 {
' GET /api/v1/guardian/dashboard ' GET /api/v1/guardian/dashboard
} }
WalkGuideBlocFacade --> VoiceCommandHandler : processText() WalkGuideCubitFacade --> VoiceCommandHandler : processText()
VoiceCommandHandler --> SttServiceFacade : delegates VoiceCommandHandler --> SttServiceFacade : delegates
VoiceCommandHandler --> TtsServiceFacade : delegates VoiceCommandHandler --> TtsServiceFacade : delegates
VoiceCommandHandler --> GoRouterFacade : delegates VoiceCommandHandler --> GoRouterFacade : delegates
VoiceCommandHandler --> SosBlocFacade : delegates VoiceCommandHandler --> CommandActionFacade : delegates
GuardianDashboardController --> GuardianDashboardService : getDashboard() GuardianDashboardController --> GuardianDashboardService : getDashboard()
GuardianDashboardService --> LocationService : aggregates GuardianDashboardService --> LocationService : aggregates

View File

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

View File

@ -43,30 +43,27 @@ skinparam note {
package "⑤ Observer Pattern [Behavioral]" #F3E5F5 { 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> # stateController : StreamController<State>
+ {abstract} on<E>(EventHandler)
+ add(Event event)
+ emit(State state) + emit(State state)
+ stream : Stream<State> + stream : Stream<State>
} }
class "WalkGuideBloc\n<<ConcreteSubject>>" as WalkGuideBlocObs { class "WalkGuideCubit\n<<ConcreteSubject>>" as WalkGuideCubitObs {
+ on<StartWalkGuide>(_onStart) + start()
+ on<StopWalkGuide>(_onStop) + stop()
+ on<CameraFrameReceived>(_onFrame) + logObstacle()
+ on<ObstacleDetected>(_onObstacle)
- _yoloDetector : YoloDetector - _yoloDetector : YoloDetector
- _ttsService : TtsService - _ttsService : TtsService
- _hapticService : HapticService - _hapticService : HapticService
} }
class "BlocBuilder<WalkGuideBloc, WalkGuideState>\n<<Observer / Widget>>" as BlocBuilderWidget { class "BlocBuilder<WalkGuideCubit, WalkGuideState>\n<<Observer / Widget>>" as BlocBuilderWidget {
+ builder(ctx, state) : Widget + builder(ctx, state) : Widget
' Rebuilds UI on every state emission ' 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 + listener(ctx, state) : void
' Side effects: TTS, haptic, navigation ' Side effects: TTS, haptic, navigation
} }
@ -84,9 +81,9 @@ package "⑤ Observer Pattern [Behavioral]" #F3E5F5 {
' Updates flutter_map markers in real-time ' Updates flutter_map markers in real-time
} }
BlocSubject <|-- WalkGuideBlocObs : extends BlocSubject <|-- WalkGuideCubitObs : extends
WalkGuideBlocObs --> BlocBuilderWidget : emits state\n(rebuilds UI) WalkGuideCubitObs --> BlocBuilderWidget : emits state\n(rebuilds UI)
WalkGuideBlocObs --> BlocListenerWidget : emits state\n(side effects) WalkGuideCubitObs --> BlocListenerWidget : emits state\n(side effects)
WebSocketObs --> GuardianMapObs : notifies\nlive location WebSocketObs --> GuardianMapObs : notifies\nlive location
} }

View File

@ -104,6 +104,13 @@
<!-- yang di-copy dari Agora open-source token builder. Tidak perlu library eksternal. --> <!-- yang di-copy dari Agora open-source token builder. Tidak perlu library eksternal. -->
<!-- Referensi: https://github.com/AgoraIO/Tools/tree/master/DynamicKey/AgoraDynamicKey/java --> <!-- 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 --> <!-- TESTING -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <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 @Override
public void registerStompEndpoints(StompEndpointRegistry registry) { 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) // Flutter connect ke: ws://host:port/ws (tanpa SockJS)
// Browser/Postman bisa pakai SockJS fallback: http://host:port/ws
registry.addEndpoint("/ws") registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*") // Allow semua origin untuk testing HP di LAN .setAllowedOriginPatterns("*"); // Allow semua origin untuk testing HP di LAN
.withSockJS(); // SockJS fallback untuk browser compatibility
// 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Map; import java.util.Map;
@ -36,35 +39,80 @@ public class CallController {
@Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora") @Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken( public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
@Valid @RequestBody CallTokenRequest req) { @Valid @RequestBody CallTokenRequest req) {
Long callerId = SecurityHelper.getCurrentUserId(); Long callerId = SecurityHelper.getCurrentUserId();
AgoraTokenResponse response = agoraTokenService.generateToken(callerId, req.getReceiverId()); AgoraTokenResponse response = agoraTokenService.generateToken(callerId, req.getReceiverId());
log.info("[CALL] Token generated | caller={} receiver={} channel={}", log.info("[CALL] Token generated | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), response.getChannelName()); callerId, req.getReceiverId(), response.getChannelName());
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate")); return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
} }
@PostMapping("/notify") @PostMapping("/notify")
@Operation(summary = "Notify receiver of incoming call") @Operation(summary = "Notify receiver of incoming call")
public ResponseEntity<ApiResponse<Void>> notifyCall( public ResponseEntity<ApiResponse<Void>> notifyCall(@Valid @RequestBody CallNotifyRequest req) {
@Valid @RequestBody CallNotifyRequest req) {
Long callerId = SecurityHelper.getCurrentUserId(); Long callerId = SecurityHelper.getCurrentUserId();
String message = callNotificationService.notifyIncomingCall(callerId, req); String message = callNotificationService.notifyIncomingCall(callerId, req);
return ResponseEntity.ok(ApiResponse.ok(null, message)); 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") @PostMapping("/end")
@Operation(summary = "Notify end of call") @Operation(summary = "Notify end of call")
public ResponseEntity<ApiResponse<Void>> endCall( public ResponseEntity<ApiResponse<Void>> endCall(@RequestBody Map<String, String> body) {
@RequestBody Map<String, Long> body) {
Long callerId = SecurityHelper.getCurrentUserId(); Long callerId = SecurityHelper.getCurrentUserId();
Long otherId = body.get("otherId"); Long otherId = Long.parseLong(body.get("otherId"));
callNotificationService.notifyCallEnded(callerId, 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")); 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 */ /** Token Agora untuk receiver — dikirim lewat FCM payload */
private String agoraToken; private String agoraToken;
private String agoraAppId;
/** UID Agora untuk receiver */ /** UID Agora untuk receiver */
private int receiverUid; private int receiverUid;
} }

View File

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

View File

@ -4,6 +4,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import java.util.List; import java.util.List;
import java.util.Map;
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@ -22,6 +23,8 @@ public class DashboardResponse {
// Status // Status
private long unreadSosCount; private long unreadSosCount;
private long unreadNotifCount; private long unreadNotifCount;
private long obstaclesToday;
private Map<String, Object> userStatus;
// Recent activity (5 terbaru) // Recent activity (5 terbaru)
private List<ActivityLogResponse> recentActivity; private List<ActivityLogResponse> recentActivity;

View File

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

View File

@ -29,6 +29,9 @@ public class LocationHistory {
private Double speed; // m/s private Double speed; // m/s
private Double heading; // derajat 0-360 private Double heading; // derajat 0-360
@Column(name = "battery_level")
private Integer batteryLevel;
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@ -1,6 +1,7 @@
package com.walkguide.exception; package com.walkguide.exception;
import com.walkguide.dto.ApiResponse; import com.walkguide.dto.ApiResponse;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
@ -29,10 +30,22 @@ public class GlobalExceptionHandler {
.body(ApiResponse.error("VALIDATION_ERROR", msg)); .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) @ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse<Object>> handleRuntime(RuntimeException ex) { 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) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("INTERNAL_ERROR", ex.getMessage())); .body(ApiResponse.error("INTERNAL_ERROR", message));
} }
@ExceptionHandler(Exception.class) @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.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository @Repository
public interface ObstacleLogRepository extends JpaRepository<ObstacleLog, Long> { public interface ObstacleLogRepository extends JpaRepository<ObstacleLog, Long> {
Page<ObstacleLog> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); 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.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key; import java.security.Key;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -82,7 +85,34 @@ public class JwtUtil {
} }
private Key getSignInKey() { private Key getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey); byte[] keyBytes = decodeSecret(secretKey);
return Keys.hmacShaKeyFor(keyBytes); 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.entity.User;
import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository; import com.walkguide.repository.UserRepository;
import com.walkguide.websocket.LocationBroadcaster;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@ -17,29 +20,39 @@ public class CallNotificationService {
private final FcmService fcmService; private final FcmService fcmService;
private final UserRepository userRepository; 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) { public String notifyIncomingCall(Long callerId, CallNotifyRequest req) {
User caller = userRepository.findById(callerId) User caller = userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found")); .orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
User receiver = userRepository.findById(req.getReceiverId()) User receiver = userRepository.findById(req.getReceiverId())
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found")); .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()) { if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId()); 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( fcmService.sendHighPriority(
receiver.getFcmToken(), receiver.getFcmToken(),
"Panggilan Masuk", "Panggilan Masuk",
@ -52,22 +65,111 @@ public class CallNotificationService {
return "Notifikasi panggilan berhasil dikirim"; 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) { public void notifyCallEnded(Long callerId, Long otherId) {
notifyCallEnded(callerId, otherId, null);
}
public void notifyCallEnded(Long callerId, Long otherId, String channelName) {
if (otherId == null) { if (otherId == null) {
return; 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 -> { userRepository.findById(otherId).ifPresent(other -> {
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) { if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
return; return;
} }
fcmService.sendToToken( fcmService.sendToToken(
other.getFcmToken(), other.getFcmToken(),
"Panggilan Berakhir", "Panggilan Berakhir",
"Panggilan telah 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; package com.walkguide.service;
import lombok.RequiredArgsConstructor; import com.google.cloud.Timestamp;
import lombok.extern.slf4j.Slf4j; 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 org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
/** /**
* FCM Service untuk push notification. * FCM Service untuk push notification dan audit notifikasi ke Firestore.
* Saat ini dalam mode LOG-ONLY agar tidak butuh Firebase credentials dulu. * Jika Firebase credential belum tersedia, service tetap aman berjalan dalam mode log-only.
* Untuk enable FCM nyata: uncomment bagian Firebase dan tambah dependency Firebase Admin SDK.
*/ */
@Service @Service
@RequiredArgsConstructor
@Slf4j
public class FcmService { public class FcmService {
public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) { private static final Logger log = LoggerFactory.getLogger(FcmService.class);
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);
// TODO: uncomment ini setelah tambah Firebase Admin SDK ke pom.xml public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) {
// dan taruh google-services-admin.json di src/main/resources/firebase/ sendInternal(fcmToken, title, body, data, false);
//
// 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 sendHighPriority(String fcmToken, String title, String body, Map<String, String> data) { public void sendHighPriority(String fcmToken, String title, String body, Map<String, String> data) {
// SOS dan incoming call pakai ini - sama untuk sekarang sendInternal(fcmToken, title, body, data, true);
sendToToken(fcmToken, title, body, data); }
@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.data.domain.PageRequest;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.HashMap;
import java.util.Map;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class GuardianDashboardService { public class GuardianDashboardService {
@ -17,6 +22,7 @@ public class GuardianDashboardService {
private final ActivityLogService activityLogService; private final ActivityLogService activityLogService;
private final SosEventRepository sosEventRepository; private final SosEventRepository sosEventRepository;
private final GuardianNotificationRepository notifRepository; private final GuardianNotificationRepository notifRepository;
private final ObstacleLogRepository obstacleLogRepository;
public DashboardResponse getDashboard(Long guardianId) { public DashboardResponse getDashboard(Long guardianId) {
var pairing = pairingRelationRepository var pairing = pairingRelationRepository
@ -40,6 +46,21 @@ public class GuardianDashboardService {
long unreadSos = sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, 100)) long unreadSos = sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, 100))
.stream().filter(s -> s.getStatus().name().equals("TRIGGERED")).count(); .stream().filter(s -> s.getStatus().name().equals("TRIGGERED")).count();
long unreadNotif = notifRepository.countByUserIdAndIsReadFalse(userId); 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() return DashboardResponse.builder()
.pairedUserId(userId) .pairedUserId(userId)
@ -49,6 +70,8 @@ public class GuardianDashboardService {
.lastLocation(lastLocation) .lastLocation(lastLocation)
.unreadSosCount(unreadSos) .unreadSosCount(unreadSos)
.unreadNotifCount(unreadNotif) .unreadNotifCount(unreadNotif)
.obstaclesToday(obstaclesToday)
.userStatus(userStatus)
.recentActivity(recentActivity) .recentActivity(recentActivity)
.build(); .build();
} }

View File

@ -42,6 +42,7 @@ public class LocationService {
.accuracy(req.getAccuracy()) .accuracy(req.getAccuracy())
.speed(req.getSpeed()) .speed(req.getSpeed())
.heading(req.getHeading()) .heading(req.getHeading())
.batteryLevel(req.getBatteryLevel())
.build(); .build();
loc = locationHistoryRepository.save(loc); loc = locationHistoryRepository.save(loc);
@ -136,6 +137,7 @@ public class LocationService {
return LocationResponse.builder() return LocationResponse.builder()
.id(l.getId()).lat(l.getLat()).lng(l.getLng()) .id(l.getId()).lat(l.getLat()).lng(l.getLng())
.accuracy(l.getAccuracy()).speed(l.getSpeed()).heading(l.getHeading()) .accuracy(l.getAccuracy()).speed(l.getSpeed()).heading(l.getHeading())
.batteryLevel(l.getBatteryLevel())
.createdAt(l.getCreatedAt()).build(); .createdAt(l.getCreatedAt()).build();
} }
} }

View File

@ -7,7 +7,6 @@ import com.walkguide.enums.*;
import com.walkguide.exception.PairingException; import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*; import com.walkguide.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -18,7 +17,6 @@ import java.util.List;
import java.util.Map; import java.util.Map;
@Service @Service
@RequiredArgsConstructor
public class PairingService { public class PairingService {
private final PairingRelationRepository pairingRelationRepository; private final PairingRelationRepository pairingRelationRepository;
@ -34,6 +32,22 @@ public class PairingService {
private static final int PAIRING_CODE_TTL_MINUTES = 15; private static final int PAIRING_CODE_TTL_MINUTES = 15;
private static final SecureRandom RANDOM = new SecureRandom(); 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 @Transactional
public PairingCodeResponse getOrCreatePairingCode(Long userId) { public PairingCodeResponse getOrCreatePairingCode(Long userId) {
User user = userRepository.findById(userId) User user = userRepository.findById(userId)
@ -69,7 +83,6 @@ public class PairingService {
@Transactional @Transactional
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) { public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
// Guardian tidak boleh punya pairing ACTIVE atau PENDING
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) { if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru."); 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."); 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() PairingRelation pairing = PairingRelation.builder()
.guardian(guardian) .guardian(guardian)
.user(user) .user(user)
@ -99,11 +158,7 @@ public class PairingService {
user.setPairingCodeExpiresAt(null); user.setPairingCodeExpiresAt(null);
userRepository.save(user); userRepository.save(user);
// Kirim FCM ke user sendPairingInviteNotification(pairing, guardian, user);
fcmService.sendToToken(user.getFcmToken(),
"Pairing Request",
"Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung",
Map.of("type", "PAIRING_INVITE", "guardianName", guardian.getDisplayName()));
activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT, activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT,
"Guardian mengirim invite ke " + user.getDisplayName(), null); "Guardian mengirim invite ke " + user.getDisplayName(), null);
@ -195,6 +250,13 @@ public class PairingService {
// ========== PRIVATE ========== // ========== PRIVATE ==========
private void seedDefaults(Long guardianId, Long userId) { 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 // Voice commands default
List<VoiceCommandConfig> defaults = List.of( List<VoiceCommandConfig> defaults = List.of(
vc(guardianId, userId, VoiceCommandKey.OPEN_WALKGUIDE, "Open Walkguide"), vc(guardianId, userId, VoiceCommandKey.OPEN_WALKGUIDE, "Open Walkguide"),
@ -261,6 +323,15 @@ public class PairingService {
return user; 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) { private void assignNewPairingCode(User user, LocalDateTime now) {
String candidate; String candidate;
do { do {
@ -307,3 +378,4 @@ public class PairingService {
.build(); .build();
} }
} }

View File

@ -7,6 +7,7 @@ import com.walkguide.entity.User;
import com.walkguide.enums.ActivityLogType; import com.walkguide.enums.ActivityLogType;
import com.walkguide.enums.PairingStatus; import com.walkguide.enums.PairingStatus;
import com.walkguide.enums.SosStatus; import com.walkguide.enums.SosStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*; import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster; import com.walkguide.websocket.LocationBroadcaster;
@ -36,6 +37,14 @@ public class SosService {
@Transactional @Transactional
public SosEventResponse triggerSos(Long userId, SosRequest req) { 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() SosEvent sos = SosEvent.builder()
.userId(userId) .userId(userId)
.triggerType(req.getTriggerType() != null ? req.getTriggerType() : "MANUAL") .triggerType(req.getTriggerType() != null ? req.getTriggerType() : "MANUAL")
@ -46,18 +55,13 @@ public class SosService {
sos = sosEventRepository.save(sos); sos = sosEventRepository.save(sos);
final SosEvent savedSos = sos; final SosEvent savedSos = sos;
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
activityLogService.createLog(user, ActivityLogType.SOS_TRIGGERED, activityLogService.createLog(user, ActivityLogType.SOS_TRIGGERED,
"SOS dikirim via " + sos.getTriggerType(), null); "SOS dikirim via " + sos.getTriggerType(), null);
SosEventResponse sosResponse = toResponse(savedSos); SosEventResponse sosResponse = toResponse(savedSos);
// Kirim ke Guardian via FCM (background) + WebSocket (foreground) // Kirim ke Guardian via FCM (background) + WebSocket (foreground)
pairingRelationRepository.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE) User guardian = activePairing.getGuardian();
.ifPresent(pairing -> {
User guardian = pairing.getGuardian();
String guardianFcm = guardian.getFcmToken(); String guardianFcm = guardian.getFcmToken();
String locStr = req.getLat() != null String locStr = req.getLat() != null
? String.format("Lat:%.4f,Lng:%.4f", req.getLat(), req.getLng()) ? 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={}", log.info("[SOS] Alert sent to Guardian={} for User={} | trigger={}",
guardian.getId(), userId, savedSos.getTriggerType()); guardian.getId(), userId, savedSos.getTriggerType());
});
return sosResponse; return sosResponse;
} }

View File

@ -3,68 +3,49 @@ package com.walkguide.websocket;
import com.walkguide.dto.response.LocationResponse; import com.walkguide.dto.response.LocationResponse;
import com.walkguide.dto.response.NotificationResponse; import com.walkguide.dto.response.NotificationResponse;
import com.walkguide.dto.response.SosEventResponse; import com.walkguide.dto.response.SosEventResponse;
import lombok.RequiredArgsConstructor; import org.slf4j.Logger;
import lombok.extern.slf4j.Slf4j; import org.slf4j.LoggerFactory;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** import java.util.Map;
* 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.
*/
@Service @Service
@RequiredArgsConstructor
@Slf4j
public class LocationBroadcaster { public class LocationBroadcaster {
private static final Logger log = LoggerFactory.getLogger(LocationBroadcaster.class);
private final SimpMessagingTemplate messagingTemplate; private final SimpMessagingTemplate messagingTemplate;
/** public LocationBroadcaster(SimpMessagingTemplate messagingTemplate) {
* Broadcast lokasi GPS user ke Guardian yang subscribe. this.messagingTemplate = messagingTemplate;
* Guardian Flutter subscribe ke: /topic/location/{userId} }
*
* @param userId ID dari ROLE_USER (bukan guardian)
* @param location Response lokasi terbaru
*/
public void broadcastLocation(Long userId, LocationResponse location) { public void broadcastLocation(Long userId, LocationResponse location) {
String destination = "/topic/location/" + userId; String destination = "/topic/location/" + userId;
messagingTemplate.convertAndSend(destination, location); messagingTemplate.convertAndSend(destination, location);
log.debug("[WS] Location broadcast {} | lat={} lng={}", log.debug("[WS] Location broadcast -> {} | lat={} lng={}",
destination, location.getLat(), location.getLng()); 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) { public void broadcastSos(Long guardianId, SosEventResponse sos) {
String destination = "/queue/sos/" + guardianId; String destination = "/queue/sos/" + guardianId;
messagingTemplate.convertAndSend(destination, sos); messagingTemplate.convertAndSend(destination, sos);
log.info("[WS] SOS broadcast {} | userId={} status={}", log.info("[WS] SOS broadcast -> {} | userId={} status={}",
destination, sos.getUserId(), sos.getStatus()); 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) { public void broadcastNotification(Long userId, NotificationResponse notification) {
String destination = "/queue/notif/" + userId; String destination = "/queue/notif/" + userId;
messagingTemplate.convertAndSend(destination, notification); messagingTemplate.convertAndSend(destination, notification);
log.debug("[WS] Notification broadcast {} | type={}", log.debug("[WS] Notification broadcast -> {} | type={}",
destination, notification.getNotifType()); 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 @@
# =================================================== DB_URL=jdbc:postgresql://<host>:<port>/<database>
# Profile: prod (production) DB_USERNAME=<database_username>
# Aktifkan dengan: --spring.profiles.active=prod DB_PASSWORD=<database_password>
# Semua nilai WAJIB diisi via environment variable JWT_SECRET=<base64_hs256_secret_at_least_32_bytes>
# Tidak ada default value — akan gagal start jika kosong JWT_EXPIRATION=86400000
# =================================================== AGORA_APP_ID=<agora_app_id>
AGORA_APP_CERTIFICATE=<agora_app_certificate>
spring: FIREBASE_CREDENTIALS_PATH=classpath:firebase/google-services-admin.json
datasource: FIREBASE_NOTIFICATIONS_COLLECTION=notifications
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

View File

@ -6,9 +6,18 @@
spring: spring:
datasource: datasource:
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001} url: ${DB_URL}
username: ${DB_USERNAME:5803024001} username: ${DB_USERNAME}
password: ${DB_PASSWORD:pw5803024001} 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: jpa:
show-sql: true show-sql: true
@ -17,12 +26,12 @@ spring:
format_sql: true format_sql: true
jwt: jwt:
secret: ${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt} secret: ${JWT_SECRET}
expiration: ${JWT_EXPIRATION:86400000} expiration: ${JWT_EXPIRATION:86400000}
agora: agora:
app-id: ${AGORA_APP_ID:} app-id: ${AGORA_APP_ID:}
app-certificate: ${AGORA_APP_CERTIFICATE:} app-certificate: ${AGORA_APP_CERTIFICATE:}
logging: logging:
level: level:

View File

@ -1,11 +1,19 @@
# ===== SERVER ===== # ===== SERVER =====
spring.config.import=optional:file:./secrets.properties,optional:file:walkguide-backend/demo/secrets.properties
server.port=${SERVER_PORT:8080} server.port=${SERVER_PORT:8080}
server.address=${SERVER_ADDRESS:0.0.0.0}
# ===== POSTGRESQL CONNECTION ===== # ===== POSTGRESQL CONNECTION =====
spring.datasource.url=${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001} spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME:5803024001} spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD:pw5803024001} spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver 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 ===== # ===== JPA / HIBERNATE =====
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
@ -19,7 +27,7 @@ spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true spring.flyway.baseline-on-migrate=true
# ===== JWT ===== # ===== JWT =====
jwt.secret=${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt} jwt.secret=${JWT_SECRET}
jwt.expiration=${JWT_EXPIRATION:86400000} jwt.expiration=${JWT_EXPIRATION:86400000}
# ===== SWAGGER ===== # ===== SWAGGER =====
@ -30,6 +38,10 @@ springdoc.api-docs.path=/v3/api-docs
agora.app-id=${AGORA_APP_ID:} agora.app-id=${AGORA_APP_ID:}
agora.app-certificate=${AGORA_APP_CERTIFICATE:} 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 =====
# WebSocket auto-dikonfigurasi oleh WebSocketConfig.java # 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.entity.User;
import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository; import com.walkguide.repository.UserRepository;
import com.walkguide.websocket.LocationBroadcaster;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -34,6 +35,9 @@ class CallNotificationServiceTest {
@Mock @Mock
private UserRepository userRepository; private UserRepository userRepository;
@Mock
private LocationBroadcaster locationBroadcaster;
@InjectMocks @InjectMocks
private CallNotificationService service; private CallNotificationService service;
@ -89,7 +93,7 @@ class CallNotificationServiceTest {
String message = service.notifyIncomingCall(1L, request); 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()); 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.dto.response.SosEventResponse;
import com.walkguide.entity.PairingRelation; import com.walkguide.entity.PairingRelation;
import com.walkguide.entity.SosEvent; import com.walkguide.entity.SosEvent;
import com.walkguide.entity.User; import com.walkguide.entity.User;
import com.walkguide.enums.PairingStatus; import com.walkguide.enums.PairingStatus;
import com.walkguide.enums.SosStatus; import com.walkguide.enums.SosStatus;
import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*; import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster; import com.walkguide.websocket.LocationBroadcaster;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -79,10 +80,10 @@ class SosServiceTest {
req.setLat(-7.257); req.setLat(-7.257);
req.setLng(112.752); req.setLng(112.752);
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos); when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(2L)).thenReturn(Optional.of(user)); when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty()); // tidak ada guardian skip FCM .thenReturn(Optional.of(activePairing));
doNothing().when(activityLogService).createLog(any(), any(), any(), any()); doNothing().when(activityLogService).createLog(any(), any(), any(), any());
SosEventResponse result = sosService.triggerSos(2L, req); SosEventResponse result = sosService.triggerSos(2L, req);
@ -103,10 +104,10 @@ class SosServiceTest {
req.setLat(-7.257); req.setLat(-7.257);
req.setLng(112.752); req.setLng(112.752);
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos); when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
when(userRepository.findById(2L)).thenReturn(Optional.of(user)); when(userRepository.findById(2L)).thenReturn(Optional.of(user));
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty()); .thenReturn(Optional.of(activePairing));
doNothing().when(activityLogService).createLog(any(), any(), any(), any()); doNothing().when(activityLogService).createLog(any(), any(), any(), any());
ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class); ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class);
@ -147,12 +148,27 @@ class SosServiceTest {
SosRequest req = new SosRequest(); SosRequest req = new SosRequest();
req.setTriggerType("MANUAL"); req.setTriggerType("MANUAL");
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos); when(userRepository.findById(99L)).thenReturn(Optional.empty());
when(userRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> sosService.triggerSos(99L, req))
assertThatThrownBy(() -> sosService.triggerSos(99L, req)) .isInstanceOf(ResourceNotFoundException.class);
.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 ===== // ===== acknowledgeSos TESTS =====

View File

@ -5,6 +5,10 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
android { android {
namespace = "com.example.walkguide_app" namespace = "com.example.walkguide_app"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion

View File

@ -2,6 +2,9 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <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_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
@ -13,7 +16,9 @@
<application <application
android:label="WalkGuide" android:label="WalkGuide"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@ -34,6 +39,9 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
</application> </application>
<queries> <queries>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <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 --> <!-- You can insert your own image assets here -->
<!-- <item> <!-- <item>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on --> <!-- 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 <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
@ -12,7 +12,7 @@
running. running.
This Theme is only used starting with V2 of Flutter's Android embedding. --> This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">#F8FAFC</item>
</style> </style>
</resources> </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.useAndroidX=true
android.enableJetifier=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("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" 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") 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 scissors
teddy bear teddy bear
hair drier hair drier
toothbrush toothbrush

View File

@ -1,97 +1,172 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 'app_cubit.dart';
import 'router.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 { class WalkGuideApp extends StatelessWidget {
const WalkGuideApp({super.key}); const WalkGuideApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const seed = Color(0xFF1A56DB); const seed = AppColors.primaryBlue;
return BlocProvider( return BlocProvider(
create: (_) => AppCubit(), create: (_) => AppCubit(),
child: MaterialApp.router( child: BlocBuilder<AppCubit, AppState>(
title: 'WalkGuide', builder: (context, state) => MaterialApp.router(
debugShowCheckedModeBanner: false, title: 'WalkGuide',
routerConfig: appRouter, debugShowCheckedModeBanner: false,
theme: ThemeData( routerConfig: appRouter,
useMaterial3: true, builder: (context, child) {
colorScheme: ColorScheme.fromSeed( final media = MediaQuery.of(context);
seedColor: seed, return MediaQuery(
brightness: Brightness.light, data: media.copyWith(
), textScaler: media.textScaler.clamp(
scaffoldBackgroundColor: const Color(0xFFF4F7FB), minScaleFactor: 0.9,
textTheme: GoogleFonts.interTextTheme(), maxScaleFactor: 1.15,
pageTransitionsTheme: const PageTransitionsTheme( ),
builders: { ),
TargetPlatform.android: ZoomPageTransitionsBuilder(), child: child ?? const SizedBox.shrink(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), );
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(), },
}, locale: state.localeCode == 'en-US'
), ? const Locale('en', 'US')
appBarTheme: const AppBarTheme( : const Locale('id', 'ID'),
centerTitle: false, supportedLocales: AppStrings.supportedLocales,
backgroundColor: Color(0xFFF4F7FB), localizationsDelegates: const [
foregroundColor: Color(0xFF0F172A), AppStringsDelegate(),
elevation: 0, GlobalMaterialLocalizations.delegate,
surfaceTintColor: Colors.transparent, GlobalWidgetsLocalizations.delegate,
), GlobalCupertinoLocalizations.delegate,
navigationBarTheme: NavigationBarThemeData( ],
elevation: 0, theme: ThemeData(
height: 76, useMaterial3: true,
backgroundColor: Colors.white.withValues(alpha: 0.96), visualDensity: VisualDensity.adaptivePlatformDensity,
indicatorColor: const Color(0xFFE0E7FF), colorScheme: ColorScheme.fromSeed(
labelTextStyle: WidgetStateProperty.resolveWith( seedColor: seed,
(states) => TextStyle( brightness: Brightness.light,
fontSize: 12, primary: seed,
fontWeight: states.contains(WidgetState.selected) secondary: AppColors.accent,
? FontWeight.w800 error: AppColors.danger,
: FontWeight.w500, ),
scaffoldBackgroundColor: AppColors.surface,
textTheme: AppTextStyles.textTheme.apply(
bodyColor: AppColors.text,
displayColor: AppColors.text,
),
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
},
),
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: AppColors.surface,
foregroundColor: AppColors.text,
elevation: 0,
surfaceTintColor: Colors.transparent,
),
cardTheme: const CardThemeData(
elevation: 0,
color: AppColors.surfaceRaised,
surfaceTintColor: Colors.transparent,
margin: EdgeInsets.zero,
shape: AppDecorations.cardShape,
),
dividerTheme: const DividerThemeData(
color: AppColors.border,
thickness: 1,
space: 1,
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: AppColors.text,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: AppColors.border),
),
), ),
), ),
), navigationBarTheme: NavigationBarThemeData(
filledButtonTheme: FilledButtonThemeData( elevation: 0,
style: FilledButton.styleFrom( height: 76,
backgroundColor: seed, backgroundColor: Colors.white,
foregroundColor: Colors.white, indicatorColor: AppColors.softBlueBg,
minimumSize: const Size(0, 50), surfaceTintColor: Colors.transparent,
textStyle: const TextStyle(fontWeight: FontWeight.w800), 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( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
), ),
), inputDecorationTheme: InputDecorationTheme(
outlinedButtonTheme: OutlinedButtonThemeData( filled: true,
style: OutlinedButton.styleFrom( fillColor: Colors.white,
minimumSize: const Size(0, 50), contentPadding:
foregroundColor: seed, const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
textStyle: const TextStyle(fontWeight: FontWeight.w800), border: OutlineInputBorder(
side: const BorderSide(color: Color(0xFFCBD5E1)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14), 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 bool online;
final String? role; final String? role;
final String? serverUrl; 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( return AppState(
online: online ?? this.online, online: online ?? this.online,
role: role ?? this.role, role: role ?? this.role,
serverUrl: serverUrl ?? this.serverUrl, 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 setOnline(bool value) => emit(state.copyWith(online: value));
void setLocaleCode(String value) => emit(state.copyWith(localeCode: value));
void clearSession() => emit(const AppState(online: true)); void clearSession() => emit(const AppState(online: true));
} }

View File

@ -1,6 +1,4 @@
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:flutter/foundation.dart';
import '../core/constants/app_constants.dart'; import '../core/constants/app_constants.dart';
import '../core/ai/obstacle_alert_strategy.dart'; import '../core/ai/obstacle_alert_strategy.dart';
import '../core/ai/obstacle_analyzer.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/call_service.dart';
import '../core/services/fcm_service.dart'; import '../core/services/fcm_service.dart';
import '../core/services/hardware_shortcut_listener.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/location_reporter_service.dart';
import '../core/services/offline_queue_service.dart'; import '../core/services/offline_queue_service.dart';
import '../core/services/stt_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/services/websocket_service.dart';
import '../core/storage/local_database.dart'; import '../core/storage/local_database.dart';
import '../core/storage/secure_storage.dart'; import '../core/storage/secure_storage.dart';
import '../core/utils/init_guard.dart';
import '../features/notifications/application/notification_cubit.dart'; import '../features/notifications/application/notification_cubit.dart';
import '../features/notifications/data/repositories/notification_repository_impl.dart'; import '../features/notifications/data/repositories/notification_repository_impl.dart';
import '../features/notifications/domain/repositories/notification_repository.dart'; import '../features/notifications/domain/repositories/notification_repository.dart';
@ -39,17 +37,24 @@ Future<void> initDependencies() async {
sl.registerLazySingleton<SttService>(() => SttService()); sl.registerLazySingleton<SttService>(() => SttService());
sl.registerLazySingleton<HapticService>(() => HapticService()); sl.registerLazySingleton<HapticService>(() => HapticService());
sl.registerLazySingleton<ObstacleAlertStrategy>( sl.registerLazySingleton<ObstacleAlertStrategy>(
() => TtsWithHapticObstacleAlertStrategy(sl<TtsService>(), sl<HapticService>()), () => TtsWithHapticObstacleAlertStrategy(
sl<TtsService>(), sl<HapticService>()),
); );
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer()); sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>())); sl.registerLazySingleton<YoloDetector>(
() => YoloDetector(sl<ObstacleAnalyzer>()));
sl.registerLazySingleton<OfflineQueueService>( sl.registerLazySingleton<OfflineQueueService>(
() => OfflineQueueService(sl<LocalDatabase>()), () => OfflineQueueService(sl<LocalDatabase>()),
); );
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>())); sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
sl.registerLazySingleton<WebSocketService>(() => WebSocketService(sl<SecureStorage>())); sl.registerLazySingleton<WebSocketService>(
sl.registerLazySingleton<LocationReporterService>(() => LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>())); () => WebSocketService(sl<SecureStorage>()));
sl.registerLazySingleton<LocationReporterService>(() =>
LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>())); sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
sl.registerLazySingleton<IncomingCallPollingService>(
() => IncomingCallPollingService(sl<ApiClient>()),
);
sl.registerLazySingleton<HardwareShortcutListener>( sl.registerLazySingleton<HardwareShortcutListener>(
() => HardwareShortcutListener(sl<ApiClient>()), () => HardwareShortcutListener(sl<ApiClient>()),
); );
@ -59,8 +64,10 @@ Future<void> initDependencies() async {
sl.registerLazySingleton<WalkGuideRepository>( sl.registerLazySingleton<WalkGuideRepository>(
() => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()), () => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()),
); );
sl.registerFactory<WalkGuideCubit>(() => WalkGuideCubit(sl<WalkGuideRepository>())); sl.registerFactory<WalkGuideCubit>(
sl.registerLazySingleton<SosRepository>(() => SosRepositoryImpl(sl<ApiClient>())); () => WalkGuideCubit(sl<WalkGuideRepository>()));
sl.registerLazySingleton<SosRepository>(
() => SosRepositoryImpl(sl<ApiClient>()));
sl.registerFactory<SosCubit>(() => SosCubit(sl<SosRepository>())); sl.registerFactory<SosCubit>(() => SosCubit(sl<SosRepository>()));
sl.registerLazySingleton<NotificationRepository>( sl.registerLazySingleton<NotificationRepository>(
() => NotificationRepositoryImpl(sl<ApiClient>(), sl<LocalDatabase>()), () => NotificationRepositoryImpl(sl<ApiClient>(), sl<LocalDatabase>()),
@ -74,13 +81,5 @@ Future<void> initDependencies() async {
await sl<ApiClient>().init(serverUrl); 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(); 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; as guardian_tools;
import '../features/home/presentation/guardian_dashboard_screen.dart' import '../features/home/presentation/guardian_dashboard_screen.dart'
as guardian_home; as guardian_home;
import '../features/manual/manual_screen.dart' as manual;
import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart' import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart'
as nav; as nav;
import '../features/notifications/presentation/screens/notification_screen.dart' import '../features/notifications/presentation/screens/notification_screen.dart'
as notifications; 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' import '../features/server_connect/server_connect_server.dart'
as server_connect; as server_connect;
import '../features/settings/presentation/screens/user_settings_screen.dart' 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'; import '../shared/widgets/app_shells.dart';
final GoRouter appRouter = GoRouter( final GoRouter appRouter = GoRouter(
initialLocation: '/splash', initialLocation: '/server-connect',
redirect: (context, state) async { redirect: (context, state) async {
final path = state.matchedLocation; final path = state.matchedLocation;
final serverUrl = await AppConstants.getServerUrl(); final serverUrl = await AppConstants.getServerUrl();
final isEditingServer =
path == '/server-connect' && state.uri.queryParameters['edit'] == '1';
final isPublicRoute = path == '/server-connect' || final isPublicRoute = path == '/server-connect' ||
path == '/splash' || path == '/splash' ||
path == '/login' || path == '/login' ||
@ -52,7 +56,8 @@ final GoRouter appRouter = GoRouter(
if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') { if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') {
return '/server-connect'; return '/server-connect';
} }
if (path == '/server-connect' && if (!isEditingServer &&
path == '/server-connect' &&
serverUrl != null && serverUrl != null &&
serverUrl.isNotEmpty) { serverUrl.isNotEmpty) {
return '/splash'; return '/splash';
@ -87,7 +92,9 @@ final GoRouter appRouter = GoRouter(
routes: [ routes: [
GoRoute( GoRoute(
path: '/server-connect', path: '/server-connect',
builder: (_, __) => const server_connect.ServerConnectScreen()), builder: (_, state) => server_connect.ServerConnectScreen(
editMode: state.uri.queryParameters['edit'] == '1',
)),
GoRoute( GoRoute(
path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()), path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()), GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()),
@ -96,7 +103,18 @@ final GoRouter appRouter = GoRouter(
builder: (_, __) => const auth_register.RegisterScreen()), builder: (_, __) => const auth_register.RegisterScreen()),
GoRoute( GoRoute(
path: '/incoming-call', 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( ShellRoute(
builder: (_, __, child) => UserShell(child: child), builder: (_, __, child) => UserShell(child: child),
routes: [ routes: [
@ -124,6 +142,9 @@ final GoRouter appRouter = GoRouter(
GoRoute( GoRoute(
path: '/user/benchmark', path: '/user/benchmark',
builder: (_, __) => const benchmark.AiBenchmarkScreen()), builder: (_, __) => const benchmark.AiBenchmarkScreen()),
GoRoute(
path: '/user/manual',
builder: (_, __) => const manual.ManualScreen()),
], ],
), ),
ShellRoute( ShellRoute(
@ -161,6 +182,12 @@ final GoRouter appRouter = GoRouter(
path: '/guardian/settings', path: '/guardian/settings',
builder: (_, __) => builder: (_, __) =>
const guardian_settings.GuardianSettingsScreen()), const guardian_settings.GuardianSettingsScreen()),
GoRoute(
path: '/guardian/call',
builder: (_, __) => const call.CallScreen(
targetLabel: 'User',
returnRoute: '/guardian/dashboard',
)),
GoRoute( GoRoute(
path: '/guardian/benchmark', path: '/guardian/benchmark',
builder: (_, __) => const benchmark.AiBenchmarkScreen()), builder: (_, __) => const benchmark.AiBenchmarkScreen()),

View File

@ -586,14 +586,31 @@ const Set<String> _walkGuideObstacleLabels = {
'bicycle', 'bicycle',
'car', 'car',
'motorcycle', 'motorcycle',
'truck',
'bus', 'bus',
'train', 'train',
'truck', 'boat',
'traffic light', 'traffic light',
'fire hydrant', 'fire hydrant',
'stop sign', 'stop sign',
'parking meter', 'parking meter',
'bench', 'bench',
'stairs',
'stair',
'pothole',
'curb',
'pole',
'bollard',
'cone',
'road cone',
'barrier',
'fence',
'door',
'trash can',
'signboard',
'crosswalk',
'sidewalk',
'wall',
'backpack', 'backpack',
'umbrella', 'umbrella',
'handbag', 'handbag',
@ -608,6 +625,7 @@ const Set<String> _walkGuideObstacleLabels = {
'bottle', 'bottle',
'cup', 'cup',
'book', 'book',
'object',
}; };
const Map<int, String> _cocoObstacleLabels = { const Map<int, String> _cocoObstacleLabels = {

View File

@ -1,13 +1,19 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class AppConstants { 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 _serverUrlKey = 'server_base_url';
static const String _selectedYoloModelKey = 'selected_yolo_model'; static const String _selectedYoloModelKey = 'selected_yolo_model';
// Ambil base URL dari SharedPreferences // Ambil base URL dari SharedPreferences
static Future<String?> getServerUrl() async { static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance(); 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 // Simpan URL setelah berhasil connect
@ -22,6 +28,9 @@ class AppConstants {
if (!cleaned.startsWith('http://') && !cleaned.startsWith('https://')) { if (!cleaned.startsWith('http://') && !cleaned.startsWith('https://')) {
cleaned = 'http://$cleaned'; cleaned = 'http://$cleaned';
} }
cleaned = cleaned
.replaceFirst('://localhost', '://127.0.0.1')
.replaceFirst('://0.0.0.0', '://127.0.0.1');
while (cleaned.endsWith('/')) { while (cleaned.endsWith('/')) {
cleaned = cleaned.substring(0, cleaned.length - 1); cleaned = cleaned.substring(0, cleaned.length - 1);
} }
@ -61,7 +70,6 @@ class AppConstants {
await prefs.setString(_selectedYoloModelKey, path); await prefs.setString(_selectedYoloModelKey, path);
} }
// Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=... // Set at build time with: --dart-define=AGORA_APP_ID=your_app_id
static const String agoraAppId = static const String agoraAppId = String.fromEnvironment('AGORA_APP_ID');
String.fromEnvironment('AGORA_APP_ID', defaultValue: '');
} }

View File

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

View File

@ -1,9 +1,19 @@
import 'package:flutter/widgets.dart';
class AppStrings { class AppStrings {
final String localeCode; final String localeCode;
const AppStrings(this.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( String get walkGuideStarted => _pick(
id: 'WalkGuide dimulai', id: 'WalkGuide dimulai',
@ -29,3 +39,21 @@ class AppStrings {
return localeCode == 'en-US' ? en : id; 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:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../constants/app_constants.dart'; import '../constants/app_constants.dart';
import '../storage/secure_storage.dart'; import '../storage/secure_storage.dart';
@ -24,8 +25,15 @@ class ApiClient {
_dio.interceptors.addAll([ _dio.interceptors.addAll([
_AuthInterceptor(_secureStorage, _dio), _AuthInterceptor(_secureStorage, _dio),
_ErrorInterceptor(), _ErrorInterceptor(),
LogInterceptor(requestBody: true, responseBody: true),
]); ]);
if (kDebugMode) {
_dio.interceptors.add(LogInterceptor(
requestBody: false,
responseBody: false,
requestHeader: false,
responseHeader: false,
));
}
} }
Dio get dio => _dio; Dio get dio => _dio;
@ -42,7 +50,8 @@ class _AuthInterceptor extends Interceptor {
_AuthInterceptor(this._storage, this._dio); _AuthInterceptor(this._storage, this._dio);
@override @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { void onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
final token = await _storage.getAccessToken(); final token = await _storage.getAccessToken();
if (token != null) { if (token != null) {
options.headers['Authorization'] = 'Bearer $token'; options.headers['Authorization'] = 'Bearer $token';
@ -52,7 +61,11 @@ class _AuthInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) async { 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; _refreshing = true;
try { try {
final refresh = await _storage.getRefreshToken(); final refresh = await _storage.getRefreshToken();
@ -78,14 +91,20 @@ class _AuthInterceptor extends Interceptor {
// Retry original request // Retry original request
err.requestOptions.headers['Authorization'] = err.requestOptions.headers['Authorization'] =
'Bearer ${data['accessToken']}'; 'Bearer ${data['accessToken']}';
final retryRes = await _dio.fetch(err.requestOptions); try {
_refreshing = false; final retryRes = await _dio.fetch(err.requestOptions);
handler.resolve(retryRes); _refreshing = false;
handler.resolve(retryRes);
} on DioException catch (retryErr) {
_refreshing = false;
handler.next(retryErr);
}
return; return;
} }
} catch (_) {} } catch (_) {
await _storage.clearAll();
}
_refreshing = false; _refreshing = false;
await _storage.clearAll();
} }
handler.next(err); handler.next(err);
} }

View File

@ -1,5 +1,8 @@
import 'dart:async';
import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:agora_rtc_engine/agora_rtc_engine.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
import '../constants/app_constants.dart'; import '../constants/app_constants.dart';
import '../network/api_client.dart'; import '../network/api_client.dart';
@ -7,9 +10,19 @@ import '../network/api_client.dart';
class CallService { class CallService {
final ApiClient _apiClient; final ApiClient _apiClient;
RtcEngine? _engine; RtcEngine? _engine;
VoidCallback? _onRemoteUserJoined;
VoidCallback? _onRemoteUserOffline;
CallService(this._apiClient); CallService(this._apiClient);
void setRemoteUserJoinedCallback(VoidCallback? callback) {
_onRemoteUserJoined = callback;
}
void setRemoteUserOfflineCallback(VoidCallback? callback) {
_onRemoteUserOffline = callback;
}
Future<Map<String, dynamic>?> requestToken({required int receiverId}) async { Future<Map<String, dynamic>?> requestToken({required int receiverId}) async {
final res = await _apiClient.dio.post( final res = await _apiClient.dio.post(
'/shared/call/token', '/shared/call/token',
@ -31,72 +44,219 @@ class CallService {
required int receiverId, required int receiverId,
required String channelName, required String channelName,
String? agoraToken, String? agoraToken,
String? agoraAppId,
int receiverUid = 0, int receiverUid = 0,
}) async { }) async {
await _apiClient.dio.post('/shared/call/notify', data: { await _apiClient.dio.post(
'receiverId': receiverId, '/shared/call/notify',
'channelName': channelName, data: {
'agoraToken': agoraToken, 'receiverId': receiverId,
'receiverUid': receiverUid, '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(); final receiverId = await getPairedReceiverId();
if (receiverId == null) return false; if (receiverId == null) return null;
final tokenData = await requestToken(receiverId: receiverId); final tokenData = await requestToken(receiverId: receiverId);
final channelName = tokenData?['channelName']?.toString(); final channelName = tokenData?['channelName']?.toString();
final token = tokenData?['token']?.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( final joined = await joinChannel(
channelName: channelName, channelName: channelName,
token: token, 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({ Future<bool> joinChannel({
required String channelName, required String channelName,
String? token, String? token,
String? appId,
int uid = 0, int uid = 0,
}) async { }) async {
final joinCompleter = Completer<bool>();
try { 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'); debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
return false; return false;
} }
if (!await _ensureMicrophonePermission()) {
debugPrint('Agora join skipped: microphone permission denied');
return false;
}
_engine ??= createAgoraRtcEngine(); _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!.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( await _engine!.joinChannel(
token: token ?? '', token: token ?? '',
channelId: channelName, channelId: channelName,
uid: uid, 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) { } catch (e) {
debugPrint('Agora join skipped: $e'); debugPrint('Agora join skipped: $e');
return false; 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 { Future<void> leave() async {
_onRemoteUserJoined = null;
_onRemoteUserOffline = null;
await _engine?.leaveChannel(); await _engine?.leaveChannel();
} }
Future<void> dispose() async { Future<void> dispose() async {
_onRemoteUserJoined = null;
_onRemoteUserOffline = null;
await _engine?.release(); await _engine?.release();
_engine = null; _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:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../../app/router.dart';
import '../network/api_client.dart'; import '../network/api_client.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
}
class FcmService { class FcmService {
final ApiClient _apiClient; final ApiClient _apiClient;
final FirebaseMessaging _messaging = FirebaseMessaging.instance; final FlutterLocalNotificationsPlugin _localNotifications =
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
FcmService(this._apiClient); FcmService(this._apiClient);
Future<void> init() async { Future<void> init() async {
if (kIsWeb) return; if (kIsWeb) return;
try { try {
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(
_firebaseMessagingBackgroundHandler);
final messaging = FirebaseMessaging.instance;
await _localNotifications.initialize( await _localNotifications.initialize(
const InitializationSettings( const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'), 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); await messaging.requestPermission(alert: true, badge: true, sound: true);
final token = await _messaging.getToken(); final token = await messaging.getToken();
if (token != null) await syncToken(token); if (token != null) await syncToken(token);
FirebaseMessaging.instance.onTokenRefresh.listen(syncToken); messaging.onTokenRefresh.listen(syncToken);
FirebaseMessaging.onMessage.listen((message) { FirebaseMessaging.onMessage.listen((message) {
debugPrint('FCM foreground: ${message.data}'); debugPrint('FCM foreground: ${message.data}');
_showLocalNotification(message); _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) { } catch (e) {
debugPrint('FCM init skipped: $e'); debugPrint('FCM init skipped: $e');
} }
@ -34,6 +64,10 @@ class FcmService {
Future<void> syncToken(String token) async { Future<void> syncToken(String token) async {
try { 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}); await _apiClient.dio.put('/auth/fcm-token', data: {'fcmToken': token});
} catch (e) { } catch (e) {
debugPrint('FCM token sync skipped: $e'); debugPrint('FCM token sync skipped: $e');
@ -42,8 +76,11 @@ class FcmService {
Future<void> _showLocalNotification(RemoteMessage message) async { Future<void> _showLocalNotification(RemoteMessage message) async {
final notification = message.notification; final notification = message.notification;
final title = notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide'; final title =
final body = notification?.body ?? message.data['body']?.toString() ?? 'Ada update baru'; notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
final body = notification?.body ??
message.data['body']?.toString() ??
'Ada update baru';
await _localNotifications.show( await _localNotifications.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000, DateTime.now().millisecondsSinceEpoch ~/ 1000,
title, title,
@ -57,7 +94,26 @@ class FcmService {
priority: Priority.high, 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'; import 'package:vibration/vibration.dart';
class HapticService { 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 { Future<void> obstacleVeryClose() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(pattern: [0, 500, 100, 500, 100, 500]); pattern: [0, 500, 100, 500, 100, 500],
fallback: HapticFeedback.heavyImpact,
obstacle: true,
);
} }
Future<void> obstacleClose() async { Future<void> obstacleClose() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(pattern: [0, 300, 100, 300]); pattern: [0, 300, 100, 300],
fallback: HapticFeedback.mediumImpact,
obstacle: true,
);
} }
Future<void> obstacleMedium() async { Future<void> obstacleMedium() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(duration: 150); duration: 150,
fallback: HapticFeedback.lightImpact,
obstacle: true,
);
} }
Future<void> sosTriggered() async { Future<void> sosTriggered() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(pattern: [0, 1000, 200, 1000, 200, 1000]); pattern: [0, 1000, 200, 1000, 200, 1000],
fallback: HapticFeedback.heavyImpact,
);
} }
Future<void> callIncoming() async { Future<void> callIncoming() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(pattern: [0, 500, 500, 500, 500, 500, 500, 500]); pattern: [0, 500, 500, 500, 500, 500, 500, 500],
fallback: HapticFeedback.mediumImpact,
);
} }
Future<void> success() async { Future<void> success() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(duration: 80); duration: 80,
fallback: HapticFeedback.selectionClick,
);
} }
Future<void> stop() async => Vibration.cancel(); Future<void> stop() async => Vibration.cancel();

View File

@ -27,11 +27,14 @@ class HardwareShortcutBinding {
class HardwareShortcutListener { class HardwareShortcutListener {
final ApiClient _apiClient; final ApiClient _apiClient;
final Map<int, HardwareShortcutBinding> _bindings = {}; final Map<int, HardwareShortcutBinding> _bindings = {};
final Map<int, DateTime> _lastHandledAt = {};
bool _listening = false; bool _listening = false;
void Function(HardwareShortcutAction action)? _onAction; void Function(HardwareShortcutAction action)? _onAction;
void Function(int buttonCode, String buttonName)? _captureCallback; void Function(int buttonCode, String buttonName)? _captureCallback;
static const Duration _repeatDebounce = Duration(milliseconds: 900);
HardwareShortcutListener(this._apiClient); HardwareShortcutListener(this._apiClient);
Future<void> startListening({ 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; _captureCallback = onCapture;
} }
@ -88,6 +92,12 @@ class HardwareShortcutListener {
final binding = _bindings[code]; final binding = _bindings[code];
if (binding == null || !binding.enabled) return false; 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); _onAction?.call(binding.action);
return true; return true;
} }
@ -103,7 +113,8 @@ HardwareShortcutBinding? _bindingFromJson(Map<String, dynamic> item) {
final action = _actionFromBackend(item['shortcutKey']?.toString()); final action = _actionFromBackend(item['shortcutKey']?.toString());
final rawCode = item['buttonCode']; final rawCode = item['buttonCode'];
final enabled = item['enabled'] != false; 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; if (action == null || code == null || code <= 0) return null;
return HardwareShortcutBinding( return HardwareShortcutBinding(
action: action, 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 'dart:async';
import 'package:battery_plus/battery_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
@ -10,6 +11,7 @@ import 'offline_queue_service.dart';
class LocationReporterService { class LocationReporterService {
final ApiClient _apiClient; final ApiClient _apiClient;
final OfflineQueueService _offlineQueue; final OfflineQueueService _offlineQueue;
final Battery _battery = Battery();
Timer? _timer; Timer? _timer;
LocationReporterService(this._apiClient, this._offlineQueue); LocationReporterService(this._apiClient, this._offlineQueue);
@ -32,12 +34,14 @@ class LocationReporterService {
try { try {
await Geolocator.requestPermission(); await Geolocator.requestPermission();
final position = await Geolocator.getCurrentPosition(); final position = await Geolocator.getCurrentPosition();
final batteryLevel = await _readBatteryLevel();
await _apiClient.dio.post('/user/location', data: { await _apiClient.dio.post('/user/location', data: {
'lat': position.latitude, 'lat': position.latitude,
'lng': position.longitude, 'lng': position.longitude,
'accuracy': position.accuracy, 'accuracy': position.accuracy,
'speed': position.speed, 'speed': position.speed,
'heading': position.heading, 'heading': position.heading,
if (batteryLevel != null) 'batteryLevel': batteryLevel,
}); });
} on DioException catch (_) { } on DioException catch (_) {
await _offlineQueue.enqueue(OfflineRequest( await _offlineQueue.enqueue(OfflineRequest(
@ -50,4 +54,12 @@ class LocationReporterService {
// GPS permission can be unavailable during desktop/web testing. // 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(); final SpeechToText _stt = SpeechToText();
bool _available = false; bool _available = false;
bool _listening = false; bool _listening = false;
bool _shouldListen = false;
bool _initializing = false;
Function(String)? onResult; Function(String)? onResult;
Future<bool> init() async { Future<bool> init() async {
if (_available) return true;
if (_initializing) return _available;
_initializing = true;
_available = await _stt.initialize( _available = await _stt.initialize(
onError: (e) => _onError(e), onError: (e) => _onError(e),
onStatus: (s) => _onStatus(s), onStatus: (s) => _onStatus(s),
); );
_initializing = false;
return _available; return _available;
} }
Future<void> startListening() async { Future<void> startListening() async {
_shouldListen = true;
if (!_available || _listening) return; if (!_available || _listening) return;
_listening = true; _listening = true;
await _stt.listen( await _stt.listen(
@ -25,14 +32,15 @@ class SttService {
onResult?.call(result.recognizedWords.toLowerCase().trim()); onResult?.call(result.recognizedWords.toLowerCase().trim());
} }
}, },
listenFor: const Duration(seconds: 10), listenFor: const Duration(seconds: 60),
pauseFor: const Duration(seconds: 3), pauseFor: const Duration(seconds: 8),
localeId: 'id_ID', localeId: 'id_ID',
cancelOnError: false, cancelOnError: false,
); );
} }
Future<void> stopListening() async { Future<void> stopListening() async {
_shouldListen = false;
_listening = false; _listening = false;
await _stt.stop(); await _stt.stop();
} }
@ -42,15 +50,17 @@ class SttService {
void _onError(dynamic error) { void _onError(dynamic error) {
_listening = false; _listening = false;
// Auto-restart setelah error if (_shouldListen) {
Future.delayed(const Duration(seconds: 1), startListening); Future.delayed(const Duration(seconds: 2), startListening);
}
} }
void _onStatus(String status) { void _onStatus(String status) {
if (status == 'done' || status == 'notListening') { if (status == 'done' || status == 'notListening') {
_listening = false; _listening = false;
// Auto-restart agar selalu mendengarkan if (_shouldListen) {
Future.delayed(const Duration(milliseconds: 500), startListening); Future.delayed(const Duration(seconds: 2), startListening);
}
} }
} }
} }

View File

@ -4,9 +4,15 @@ class TtsService {
final FlutterTts _tts = FlutterTts(); final FlutterTts _tts = FlutterTts();
final List<String> _queue = []; final List<String> _queue = [];
bool _speaking = false; bool _speaking = false;
bool _initialized = false;
String _lastSpoken = ''; 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.setLanguage(language);
await _tts.setPitch(pitch); await _tts.setPitch(pitch);
await _tts.setSpeechRate(rate); await _tts.setSpeechRate(rate);
@ -15,11 +21,25 @@ class TtsService {
_speaking = false; _speaking = false;
_processQueue(); _processQueue();
}); });
_initialized = true;
} }
/// Tambah ke antrian - tidak memotong yg sedang bicara /// Tambah ke antrian - tidak memotong yg sedang bicara
void speak(String text) { void speak(String text) {
if (text.isEmpty) return; 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); _queue.add(text);
if (!_speaking) _processQueue(); if (!_speaking) _processQueue();
} }
@ -27,6 +47,7 @@ class TtsService {
/// Potong TTS sekarang dan langsung ucapkan ini - untuk obstacle alert /// Potong TTS sekarang dan langsung ucapkan ini - untuk obstacle alert
Future<void> speakImmediate(String text) async { Future<void> speakImmediate(String text) async {
if (text.isEmpty) return; if (text.isEmpty) return;
await init();
_queue.clear(); _queue.clear();
await _tts.stop(); await _tts.stop();
_speaking = true; _speaking = true;
@ -43,9 +64,20 @@ class TtsService {
String get lastSpoken => _lastSpoken; String get lastSpoken => _lastSpoken;
bool get isSpeaking => _speaking; bool get isSpeaking => _speaking;
Future<void> setLanguage(String lang) async => _tts.setLanguage(lang); Future<void> setLanguage(String lang) async {
Future<void> setPitch(double pitch) async => _tts.setPitch(pitch); await init(language: lang);
Future<void> setRate(double rate) async => _tts.setSpeechRate(rate); 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() { void repeatLast() {
if (_lastSpoken.isNotEmpty) speak(_lastSpoken); if (_lastSpoken.isNotEmpty) speak(_lastSpoken);
@ -58,4 +90,4 @@ class TtsService {
_lastSpoken = text; _lastSpoken = text;
_tts.speak(text); _tts.speak(text);
} }
} }

View File

@ -19,6 +19,8 @@ class VoiceCommand {
/// Callback yang dipanggil saat command terdeteksi /// Callback yang dipanggil saat command terdeteksi
/// Registered oleh router/screen yang relevan /// Registered oleh router/screen yang relevan
typedef CommandCallback = void Function(VoiceCommandKey key); typedef CommandCallback = void Function(VoiceCommandKey key);
typedef CommandRouter = void Function(String route);
typedef CommandAction = void Function();
class VoiceCommandHandler { class VoiceCommandHandler {
final SttService _stt; final SttService _stt;
@ -26,9 +28,19 @@ class VoiceCommandHandler {
List<VoiceCommand> _commands = []; List<VoiceCommand> _commands = [];
CommandCallback? onCommand; CommandCallback? onCommand;
CommandRouter? _router;
final Map<VoiceCommandKey, CommandAction> _actions = {};
VoiceCommandHandler(this._stt, this._tts); 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) { void loadCommands(List<VoiceCommand> commands) {
_commands = commands; _commands = commands;
_stt.onResult = _processText; _stt.onResult = _processText;
@ -66,9 +78,28 @@ class VoiceCommandHandler {
} }
void _handleCommand(VoiceCommandKey key) { void _handleCommand(VoiceCommandKey key) {
_routeFor(key);
_actions[key]?.call();
onCommand?.call(key); onCommand?.call(key);
// Built-in actions for TTS-only commands // Built-in actions for TTS-only commands
if (key == VoiceCommandKey.repeatLast) _tts.repeatLast(); if (key == VoiceCommandKey.repeatLast) _tts.repeatLast();
if (key == VoiceCommandKey.stopTts) _tts.stop(); 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. /// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user.
/// ///
/// Subscriptions yang dipakai: /// Subscriptions yang dipakai:
/// Guardian /topic/location/{userId} live GPS update /// Guardian â /topic/location/{userId} live GPS update
/// Guardian /queue/sos/{guardianId} SOS alert real-time /// Guardian â /queue/sos/{guardianId} SOS alert real-time
/// User /queue/notif/{userId} notifikasi dari Guardian /// User â /queue/notif/{userId} notifikasi dari Guardian
class WebSocketService { class WebSocketService {
final SecureStorage _storage; final SecureStorage _storage;
@ -26,11 +26,13 @@ class WebSocketService {
void Function(double lat, double lng)? _onLocation; void Function(double lat, double lng)? _onLocation;
void Function(Map<String, dynamic> sosData)? _onSos; void Function(Map<String, dynamic> sosData)? _onSos;
void Function(Map<String, dynamic> notifData)? _onNotif; void Function(Map<String, dynamic> notifData)? _onNotif;
void Function(Map<String, dynamic> callData)? _onCall;
// Subscription frames (untuk unsubscribe) // Subscription frames (untuk unsubscribe)
StompUnsubscribe? _locationUnsub; StompUnsubscribe? _locationUnsub;
StompUnsubscribe? _sosUnsub; StompUnsubscribe? _sosUnsub;
StompUnsubscribe? _notifUnsub; StompUnsubscribe? _notifUnsub;
StompUnsubscribe? _callUnsub;
WebSocketService(this._storage); WebSocketService(this._storage);
@ -88,18 +90,18 @@ class WebSocketService {
await completer.future.timeout(const Duration(seconds: 5)); await completer.future.timeout(const Duration(seconds: 5));
} catch (e) { } catch (e) {
debugPrint('[WS] Connect timeout/error: $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. /// Subscribe ke live GPS updates dari User.
/// Guardian panggil ini setelah connect. /// Guardian panggil ini setelah connect.
/// [userId] = ID dari ROLE_USER yang dipair. /// [userId] = ID dari ROLE_USER yang dipair.
void subscribeLocation(String userId, void subscribeLocation(
void Function(double lat, double lng) callback) { String userId, void Function(double lat, double lng) callback) {
_onLocation = callback; _onLocation = callback;
if (_client == null || !_connected) { if (_client == null || !_connected) {
debugPrint('[WS] subscribeLocation skipped not connected'); debugPrint('[WS] subscribeLocation skipped — not connected');
return; return;
} }
_locationUnsub?.call(); // unsubscribe sebelumnya jika ada _locationUnsub?.call(); // unsubscribe sebelumnya jika ada
@ -107,8 +109,7 @@ class WebSocketService {
destination: '/topic/location/$userId', destination: '/topic/location/$userId',
callback: (frame) { callback: (frame) {
try { try {
final data = final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
final lat = (data['lat'] as num?)?.toDouble(); final lat = (data['lat'] as num?)?.toDouble();
final lng = (data['lng'] as num?)?.toDouble(); final lng = (data['lng'] as num?)?.toDouble();
if (lat != null && lng != null) { if (lat != null && lng != null) {
@ -135,8 +136,7 @@ class WebSocketService {
destination: '/queue/sos/$guardianId', destination: '/queue/sos/$guardianId',
callback: (frame) { callback: (frame) {
try { try {
final data = final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
_onSos?.call(data); _onSos?.call(data);
} catch (e) { } catch (e) {
debugPrint('[WS] SOS parse error: $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. /// [userId] = ID dari ROLE_USER yang login.
void subscribeNotification( void subscribeNotification(
void Function(Map<String, dynamic> notifData) callback) { void Function(Map<String, dynamic> notifData) callback) {
@ -161,8 +161,7 @@ class WebSocketService {
destination: '/queue/notif/$userId', destination: '/queue/notif/$userId',
callback: (frame) { callback: (frame) {
try { try {
final data = final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
_onNotif?.call(data); _onNotif?.call(data);
} catch (e) { } catch (e) {
debugPrint('[WS] Notif parse error: $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. /// Disconnect dan cleanup semua subscriptions.
Future<void> disconnect() async { Future<void> disconnect() async {
_locationUnsub?.call(); _locationUnsub?.call();
_sosUnsub?.call(); _sosUnsub?.call();
_notifUnsub?.call(); _notifUnsub?.call();
_callUnsub?.call();
_locationUnsub = null; _locationUnsub = null;
_sosUnsub = null; _sosUnsub = null;
_notifUnsub = null; _notifUnsub = null;
_callUnsub = null;
_client?.deactivate(); _client?.deactivate();
_client = null; _client = null;
_connected = false; _connected = false;
} }
// Legacy compat lama pakai onMessage raw // Legacy compat â lama pakai onMessage raw
void send(Object message) { void send(Object message) {
debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.'); debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.');
} }

View File

@ -1,10 +1,28 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AppColors { 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 danger = Color(0xFFDC2626);
static const success = Color(0xFF16A34A); static const success = Color(0xFF059669);
static const surface = Color(0xFFF8FAFC); static const surface = softBlueBg;
static const text = Color(0xFF0F172A); static const surfaceRaised = cardWhite;
static const muted = Color(0xFF64748B); 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/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/theme/app_colors.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; Dio get _api => sl<ApiClient>().dio;
@ -103,91 +106,114 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return DecoratedBox(
child: Padding( decoration: const BoxDecoration(
padding: const EdgeInsets.all(16), gradient: LinearGradient(
child: Column( colors: [AppColors.softBlueBg, Colors.white],
crossAxisAlignment: CrossAxisAlignment.start, begin: Alignment.topCenter,
children: [ end: Alignment.bottomCenter,
// Header ),
Row( ),
child: SafeArea(
child: FadeSlideWrapper(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( // Header
child: Column( Row(
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Expanded(
Text( child: Column(
'Activity Log', crossAxisAlignment: CrossAxisAlignment.start,
style: Theme.of(context) children: [
.textTheme Text(
.headlineSmall 'Activity Log',
?.copyWith(fontWeight: FontWeight.w800), style: AppTextStyles.heading,
),
Text(
'${_items.length} aktivitas tercatat',
style: const TextStyle(color: AppColors.muted),
),
],
), ),
Text( ),
'${_items.length} aktivitas tercatat', IconButton(
style: const TextStyle(color: AppColors.muted), 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( const SizedBox(height: 16),
onPressed: _load,
icon: const Icon(Icons.refresh), // Body
tooltip: 'Refresh', 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) { Widget build(BuildContext context) {
final meta = _logMeta(item.logType); final meta = _logMeta(item.logType);
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 10),
child: Row( child: Container(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(14),
children: [ decoration: BoxDecoration(
// Timeline dot + line color: AppColors.cardWhite,
Column( borderRadius: AppDecorations.cardRadius,
children: [ border: Border.all(color: AppColors.border),
Container( boxShadow: AppDecorations.cardShadow,
width: 36, ),
height: 36, child: Row(
decoration: BoxDecoration( crossAxisAlignment: CrossAxisAlignment.start,
color: meta.color.withValues(alpha: 0.12), children: [
shape: BoxShape.circle, // 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( const SizedBox(width: 12),
width: 1.5, // Content
height: 20, Expanded(
color: const Color(0xFFE2E8F0), child: Padding(
), padding: const EdgeInsets.only(top: 4),
], child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(width: 12), children: [
// Content Row(
Expanded( children: [
child: Padding( Expanded(
padding: const EdgeInsets.only(top: 4), child: Text(
child: Column( meta.label,
crossAxisAlignment: CrossAxisAlignment.start, style: TextStyle(
children: [ fontWeight: FontWeight.w700,
Row( color: meta.color,
children: [ fontSize: 13,
Expanded( ),
child: Text(
meta.label,
style: TextStyle(
fontWeight: FontWeight.w700,
color: meta.color,
fontSize: 13,
), ),
), ),
), Text(
Text( _formatTime(item.createdAt),
_formatTime(item.createdAt), style: const TextStyle(
style: const TextStyle( color: AppColors.muted, fontSize: 11),
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),
),
), ),
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
child: Column( child: Container(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.all(24),
children: [ decoration: const BoxDecoration(
const Icon(Icons.wifi_off, size: 48, color: AppColors.muted), color: AppColors.cardWhite,
const SizedBox(height: 12), borderRadius: AppDecorations.cardRadius,
Text(message, boxShadow: AppDecorations.cardShadow,
textAlign: TextAlign.center, ),
style: const TextStyle(color: AppColors.muted)), child: Column(
const SizedBox(height: 16), mainAxisSize: MainAxisSize.min,
FilledButton.icon( children: [
onPressed: onRetry, const Icon(Icons.wifi_off, size: 48, color: AppColors.muted),
icon: const Icon(Icons.refresh), const SizedBox(height: 12),
label: const Text('Coba lagi'), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
child: Column( child: Container(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.all(24),
children: [ decoration: const BoxDecoration(
const Icon(Icons.history, size: 64, color: AppColors.muted), color: AppColors.cardWhite,
const SizedBox(height: 12), borderRadius: AppDecorations.cardRadius,
Text( boxShadow: AppDecorations.cardShadow,
filter == 'ALL' ),
? 'Belum ada aktivitas' child: Column(
: 'Tidak ada aktivitas "$filter"', mainAxisSize: MainAxisSize.min,
style: const TextStyle( children: [
fontSize: 16, const Icon(Icons.history, size: 64, color: AppColors.muted),
fontWeight: FontWeight.w600, const SizedBox(height: 12),
color: AppColors.muted), 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 '../../app/injection_container.dart';
import '../../core/ai/detection_export.dart'; import '../../core/ai/detection_export.dart';
import '../../core/constants/app_constants.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/services/tts_service.dart';
import '../../core/utils/operation_guard.dart'; import '../../core/utils/operation_guard.dart';
import '../../shared/widgets/animations/animations.dart';
import '../../shared/widgets/feature_page.dart'; import '../../shared/widgets/feature_page.dart';
class AiBenchmarkScreen extends StatefulWidget { class AiBenchmarkScreen extends StatefulWidget {
@ -116,18 +119,22 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
CameraController? controller; CameraController? controller;
await guarded<void>( await guarded<void>(
() async { () async {
final cameras = final cameras =
await availableCameras().timeout(const Duration(seconds: 3)); await availableCameras().timeout(const Duration(seconds: 3));
if (cameras.isNotEmpty) { if (cameras.isNotEmpty) {
final activeController = CameraController( final activeController = CameraController(
cameras.first, cameras.first,
ResolutionPreset.low, ResolutionPreset.low,
enableAudio: false, enableAudio: false,
); );
controller = activeController; controller = activeController;
await activeController.initialize().timeout(const Duration(seconds: 5)); await activeController
await activeController.takePicture().timeout(const Duration(seconds: 5)); .initialize()
} .timeout(const Duration(seconds: 5));
await activeController
.takePicture()
.timeout(const Duration(seconds: 5));
}
}, },
onError: (_) {}, onError: (_) {},
); );
@ -198,7 +205,11 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
label: const Text('Clear log'), label: const Text('Clear log'),
), ),
const SizedBox(height: 16), 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) if (_runs.isEmpty)
const FeatureEmptyPanel( const FeatureEmptyPanel(
icon: Icons.speed, icon: Icons.speed,
@ -224,9 +235,10 @@ class _BenchmarkCard extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.cardWhite,
borderRadius: BorderRadius.circular(16), borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFE2E8F0)), border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -262,7 +274,8 @@ class _StatusBox extends StatelessWidget {
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2), color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(14), borderRadius: AppDecorations.cardRadius,
boxShadow: AppDecorations.cardShadow,
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@ -279,17 +292,17 @@ class _StatusBox extends StatelessWidget {
Future<List<String>> _discoverTfliteModels() async { Future<List<String>> _discoverTfliteModels() async {
return await guarded<List<String>>( return await guarded<List<String>>(
() async { () async {
final manifestRaw = await rootBundle.loadString('AssetManifest.json'); final manifestRaw = await rootBundle.loadString('AssetManifest.json');
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>; final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
final models = manifest.keys final models = manifest.keys
.where((key) => .where((key) =>
key.startsWith('assets/models/') && key.endsWith('.tflite')) key.startsWith('assets/models/') && key.endsWith('.tflite'))
.toList() .toList()
..sort(); ..sort();
return models; return models;
}, },
) ?? ) ??
const []; 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 'package:shared_preferences/shared_preferences.dart';
import '../../app/app_cubit.dart'; import '../../app/app_cubit.dart';
import '../../app/router.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.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/offline_queue_service.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
import '../../core/services/websocket_service.dart'; import '../../core/services/websocket_service.dart';
import '../../core/storage/secure_storage.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 // LoginScreen
@ -76,7 +82,8 @@ class _LoginScreenState extends State<LoginScreen> {
}, },
onError: (message) => _snack(context, message), onError: (message) => _snack(context, message),
fallback: 'Login gagal. Periksa email dan password kamu.', 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); if (mounted) setState(() => _loading = false);
} }
@ -147,125 +154,152 @@ class _AuthFrame extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFEAF4FF), backgroundColor: AppColors.softBlueBg,
body: Stack( body: LayoutBuilder(
children: [ builder: (context, constraints) {
const Positioned.fill( final compact =
child: DecoratedBox( constraints.maxWidth < 480 || constraints.maxHeight < 720;
decoration: BoxDecoration( return DecoratedBox(
gradient: LinearGradient( decoration: const BoxDecoration(
begin: Alignment.topLeft, gradient: LinearGradient(
end: Alignment.bottomRight, begin: Alignment.topCenter,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)], end: Alignment.bottomCenter,
), colors: [
AppColors.softBlueBg,
Colors.white,
AppColors.softPinkBg,
],
), ),
), ),
), child: SafeArea(
Positioned( child: Center(
top: -90, child: SingleChildScrollView(
right: -60, keyboardDismissBehavior:
child: TweenAnimationBuilder<double>( ScrollViewKeyboardDismissBehavior.onDrag,
tween: Tween(begin: 0.85, end: 1), padding: EdgeInsets.fromLTRB(
duration: const Duration(milliseconds: 900), compact ? 14 : 24,
curve: Curves.easeOutCubic, compact ? 12 : 24,
builder: (_, value, child) => Transform.scale( compact ? 14 : 24,
scale: value, 20 + MediaQuery.of(context).viewInsets.bottom,
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: RepaintBoundary( child: ConstrainedBox(
child: Container( constraints: BoxConstraints(maxWidth: compact ? 380 : 430),
decoration: BoxDecoration( child: TweenAnimationBuilder<double>(
color: Colors.white.withValues(alpha: 0.96), tween: Tween(begin: 18, end: 0),
borderRadius: BorderRadius.circular(30), duration: const Duration(milliseconds: 520),
border: Border.all( curve: Curves.easeOutCubic,
color: Colors.white.withValues(alpha: 0.8)), builder: (_, offset, child) => Opacity(
boxShadow: [ opacity: (1 - offset / 18).clamp(0.0, 1.0),
BoxShadow( child: Transform.translate(
color: offset: Offset(0, offset),
const Color(0xFF1E3A8A).withValues(alpha: 0.14), child: child,
blurRadius: 40, ),
offset: const Offset(0, 20),
),
],
), ),
child: Padding( child: RepaintBoundary(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24), child: Container(
child: Column( decoration: BoxDecoration(
crossAxisAlignment: CrossAxisAlignment.stretch, color: AppColors.cardWhite,
children: [ borderRadius:
Row( 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: [ children: [
Container( Row(
width: 56, children: [
height: 56, Container(
decoration: BoxDecoration( width: compact ? 44 : 56,
color: const Color(0xFF1D4ED8), height: compact ? 44 : 56,
borderRadius: BorderRadius.circular(18), decoration: BoxDecoration(
), gradient: AppDecorations.blueGradient,
child: const Icon(Icons.navigation_rounded, borderRadius: BorderRadius.circular(16),
color: Colors.white, size: 30), 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), SizedBox(height: compact ? 14 : 16),
const Expanded( if (!compact)
child: Text( Container(
'WalkGuide', padding: const EdgeInsets.symmetric(
style: TextStyle( horizontal: 10, vertical: 6),
fontSize: 18, decoration: BoxDecoration(
fontWeight: FontWeight.w900, color: AppColors.softBlueBg,
color: Color(0xFF0F172A), 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) { void _startPostLoginServices(String serverUrl) {
Future.microtask(() async { Future.microtask(() async {
await sl<WebSocketService>() sl<IncomingCallPollingService>().start();
.connect(serverUrl) await sl<FcmService>().init().timeout(const Duration(seconds: 4));
.timeout(const Duration(seconds: 2)); 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>() await sl<OfflineQueueService>()
.syncPending(sl<ApiClient>()) .syncPending(sl<ApiClient>())
.timeout(const Duration(seconds: 3)); .timeout(const Duration(seconds: 3));

View File

@ -7,6 +7,10 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.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 // RegisterScreen
@ -69,7 +73,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
}, },
onError: (message) => _snack(context, message), onError: (message) => _snack(context, message),
fallback: 'Registrasi gagal. Periksa data akun kamu.', 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); if (mounted) setState(() => _loading = false);
} }
@ -128,7 +133,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _role == 'USER' color: _role == 'USER'
? const Color(0xFFEFF6FF) ? AppColors.softBlueBg
: const Color(0xFFF0FDF4), : const Color(0xFFF0FDF4),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
@ -234,18 +239,19 @@ class _RoleCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return BounceTap(
onTap: onTap, onTap: onTap,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? const Color(0xFFEFF6FF) : Colors.white, color: selected ? AppColors.softBlueBg : Colors.white,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: selected ? const Color(0xFF1A56DB) : const Color(0xFFE2E8F0), color: selected ? AppColors.primaryBlue : AppColors.border,
width: selected ? 2 : 1, width: selected ? 2 : 1,
), ),
boxShadow: selected ? AppDecorations.cardShadow : null,
), ),
child: Row( child: Row(
children: [ children: [
@ -253,10 +259,9 @@ class _RoleCard extends StatelessWidget {
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected color:
? const Color(0xFF1A56DB) selected ? AppColors.primaryBlue : const Color(0xFFF1F5F9),
: const Color(0xFFF1F5F9), borderRadius: BorderRadius.circular(50),
borderRadius: BorderRadius.circular(12),
), ),
child: Icon(icon, child: Icon(icon,
color: selected ? Colors.white : const Color(0xFF64748B)), color: selected ? Colors.white : const Color(0xFF64748B)),
@ -267,16 +272,16 @@ class _RoleCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, Text(title,
style: const TextStyle( style: AppTextStyles.subheading.copyWith(fontSize: 16)),
fontWeight: FontWeight.w800, fontSize: 16)),
Text(subtitle, Text(subtitle,
style: const TextStyle( style: const TextStyle(
color: Color(0xFF64748B), fontSize: 13)), color: AppColors.muted, fontSize: 13)),
], ],
), ),
), ),
if (selected) 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFEAF4FF), backgroundColor: AppColors.softBlueBg,
body: Stack( body: LayoutBuilder(
children: [ builder: (context, constraints) {
const Positioned.fill( final compact =
child: DecoratedBox( constraints.maxWidth < 480 || constraints.maxHeight < 720;
decoration: BoxDecoration( return DecoratedBox(
gradient: LinearGradient( decoration: const BoxDecoration(
begin: Alignment.topLeft, gradient: LinearGradient(
end: Alignment.bottomRight, begin: Alignment.topCenter,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)], end: Alignment.bottomCenter,
), colors: [
AppColors.softBlueBg,
Colors.white,
AppColors.softPinkBg,
],
), ),
), ),
), child: SafeArea(
Positioned( child: Center(
top: -90, child: SingleChildScrollView(
right: -60, keyboardDismissBehavior:
child: TweenAnimationBuilder<double>( ScrollViewKeyboardDismissBehavior.onDrag,
tween: Tween(begin: 0.85, end: 1), padding: EdgeInsets.fromLTRB(
duration: const Duration(milliseconds: 900), compact ? 14 : 24,
curve: Curves.easeOutCubic, compact ? 12 : 24,
builder: (_, value, child) => Transform.scale( compact ? 14 : 24,
scale: value, 20 + MediaQuery.of(context).viewInsets.bottom,
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: RepaintBoundary( child: ConstrainedBox(
child: Container( constraints: BoxConstraints(maxWidth: compact ? 380 : 430),
decoration: BoxDecoration( child: TweenAnimationBuilder<double>(
color: Colors.white.withValues(alpha: 0.96), tween: Tween(begin: 18, end: 0),
borderRadius: BorderRadius.circular(30), duration: const Duration(milliseconds: 520),
border: Border.all( curve: Curves.easeOutCubic,
color: Colors.white.withValues(alpha: 0.8)), builder: (_, offset, child) => Opacity(
boxShadow: [ opacity: (1 - offset / 18).clamp(0.0, 1.0),
BoxShadow( child: Transform.translate(
color: offset: Offset(0, offset),
const Color(0xFF1E3A8A).withValues(alpha: 0.14), child: child,
blurRadius: 40, ),
offset: const Offset(0, 20),
),
],
), ),
child: Padding( child: RepaintBoundary(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24), child: Container(
child: Column( decoration: BoxDecoration(
crossAxisAlignment: CrossAxisAlignment.stretch, color: AppColors.cardWhite,
children: [ borderRadius:
Row( 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: [ children: [
Container( Row(
width: 56, children: [
height: 56, Container(
decoration: BoxDecoration( width: compact ? 44 : 56,
color: const Color(0xFF1D4ED8), height: compact ? 44 : 56,
borderRadius: BorderRadius.circular(18), decoration: BoxDecoration(
), gradient: AppDecorations.blueGradient,
child: const Icon(Icons.navigation_rounded, borderRadius: BorderRadius.circular(16),
color: Colors.white, size: 30), boxShadow: const [
), BoxShadow(
const SizedBox(width: 12), color: Color(0x334A90D9),
const Expanded( blurRadius: 18,
child: Text( offset: Offset(0, 8),
'WalkGuide', ),
style: TextStyle( ],
fontSize: 18, ),
fontWeight: FontWeight.w900, child: Icon(Icons.navigation_rounded,
color: Color(0xFF0F172A), 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:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../app/router.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.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 '../../core/storage/secure_storage.dart';
import '../../shared/widgets/walkguide_loading_screen.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SplashScreen // SplashScreen
@ -32,110 +40,101 @@ class SplashScreen extends StatefulWidget {
class _SplashScreenState extends State<SplashScreen> class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late final AnimationController _animCtrl; late final AnimationController _screenCtrl;
late final Animation<double> _fadeAnim; late final Animation<double> _screenFade;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_animCtrl = AnimationController( _screenCtrl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 700)); vsync: this,
_fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeIn); duration: const Duration(milliseconds: 260),
_animCtrl.forward(); value: 1,
);
_screenFade = CurvedAnimation(
parent: _screenCtrl,
curve: Curves.easeOutCubic,
);
_route(); _route();
} }
@override @override
void dispose() { void dispose() {
_animCtrl.dispose(); _screenCtrl.dispose();
super.dispose(); super.dispose();
} }
Future<void> _route() async { Future<void> _route() async {
final routed = await runFriendlyAction( final routed = await runFriendlyAction(
() async { () async {
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash. // Animasi logo selalu tampil minimal 900ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 900));
final storage = sl<SecureStorage>(); final storage = sl<SecureStorage>();
final token = final token = await storage.getAccessToken().timeout(
await storage.getAccessToken().timeout(const Duration(seconds: 3)); const Duration(seconds: 3),
final role = );
await storage.getUserRole().timeout(const Duration(seconds: 3)); final role = await storage.getUserRole().timeout(
const Duration(seconds: 3),
);
if (!mounted) return; if (!mounted) return;
if (token == null || role == null) { if (token == null || role == null) {
context.go('/login'); await _fadeOutThenGo('/login');
return; return;
} }
final serverUrl = await AppConstants.getServerUrl();
if (serverUrl != null && serverUrl.isNotEmpty) {
_startAutoLoginServices(serverUrl);
} else {
sl<IncomingCallPollingService>().start();
}
// Auto-login: arahkan ke home sesuai role. // Auto-login: arahkan ke home sesuai role.
context.go(role == 'ROLE_GUARDIAN' await _fadeOutThenGo(
? '/guardian/dashboard' role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide',
: '/user/walkguide'); );
}, },
onError: (_) {}, onError: (_) {},
fallback: 'Sesi belum bisa dipulihkan.', 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return FadeTransition(
backgroundColor: const Color(0xFF1A56DB), opacity: _screenFade,
body: Center( child: const WalkGuideLoadingScreen(
child: FadeTransition( subtitle: 'Restoring your session',
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,
),
),
],
),
),
), ),
); );
} }
} }
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 '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.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'; import '../../shared/widgets/feature_page.dart';
class GuardianMapScreen extends StatefulWidget { class GuardianMapScreen extends StatefulWidget {
@ -106,8 +109,9 @@ class _GuardianMapCard extends StatelessWidget {
final center = _pointFrom(location) ?? final center = _pointFrom(location) ??
(points.isNotEmpty ? points.first : null) ?? (points.isNotEmpty ? points.first : null) ??
const LatLng(-7.2575, 112.7521); const LatLng(-7.2575, 112.7521);
return ClipRRect( return Container(
borderRadius: BorderRadius.circular(20), decoration: AppDecorations.card,
clipBehavior: Clip.antiAlias,
child: FlutterMap( child: FlutterMap(
options: MapOptions(initialCenter: center, initialZoom: 16), options: MapOptions(initialCenter: center, initialZoom: 16),
children: [ children: [
@ -121,7 +125,7 @@ class _GuardianMapCard extends StatelessWidget {
Polyline( Polyline(
points: points, points: points,
strokeWidth: 4, strokeWidth: 4,
color: const Color(0xFF2563EB), color: AppColors.primaryBlue,
), ),
], ],
), ),
@ -171,10 +175,18 @@ class _TimelineList extends StatelessWidget {
), ),
); );
} }
return ListView.separated( return ListView(
itemCount: segments.length, children: [
separatorBuilder: (_, __) => const SizedBox(height: 10), StaggerWrapper(
itemBuilder: (_, index) => _TimelineCard(segment: segments[index]), 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( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.cardWhite,
borderRadius: BorderRadius.circular(16), borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFE2E8F0)), border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
), ),
child: Row( child: Row(
children: [ children: [
@ -199,10 +212,10 @@ class _TimelineCard extends StatelessWidget {
width: 42, width: 42,
height: 42, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFEFF6FF), color: AppColors.softBlueBg,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(50),
), ),
child: Icon(segment.icon, color: const Color(0xFF1D4ED8)), child: Icon(segment.icon, color: AppColors.primaryBlue),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(

View File

@ -8,6 +8,9 @@ import 'package:record/record.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.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'; import '../../shared/widgets/feature_page.dart';
class GuardianSendNotifScreen extends StatefulWidget { class GuardianSendNotifScreen extends StatefulWidget {
@ -132,132 +135,130 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
subtitle: 'Kirim pesan singkat ke User yang sudah pairing', subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
child: ListView( child: ListView(
children: [ children: [
Container( FadeSlideWrapper(
padding: const EdgeInsets.all(18), child: Container(
decoration: BoxDecoration( padding: const EdgeInsets.all(18),
color: Colors.white, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), color: AppColors.cardWhite,
border: Border.all(color: const Color(0xFFE2E8F0)), borderRadius: AppDecorations.cardRadius,
boxShadow: [ border: Border.all(color: AppColors.border),
BoxShadow( boxShadow: AppDecorations.cardShadow,
color: const Color(0xFF1E293B).withValues(alpha: 0.06), ),
blurRadius: 22, child: Column(
offset: const Offset(0, 12), crossAxisAlignment: CrossAxisAlignment.stretch,
), children: [
], SegmentedButton<bool>(
), segments: const [
child: Column( ButtonSegment(
crossAxisAlignment: CrossAxisAlignment.stretch, value: false,
children: [ icon: Icon(Icons.message_outlined),
SegmentedButton<bool>( label: Text('Text'),
segments: const [ ),
ButtonSegment( ButtonSegment(
value: false, value: true,
icon: Icon(Icons.message_outlined), icon: Icon(Icons.mic_none_outlined),
label: Text('Text'), 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, if (_voiceMode) ...[
icon: Icon(Icons.mic_none_outlined), const SizedBox(height: 14),
label: Text('Voice'), 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), const SizedBox(height: 14),
Container( FilledButton.icon(
padding: const EdgeInsets.all(14), onPressed: _loading ? null : _send,
decoration: BoxDecoration( icon: _loading
color: const Color(0xFFF8FAFC), ? const SizedBox(
borderRadius: BorderRadius.circular(16), width: 18,
border: Border.all(color: const Color(0xFFE2E8F0)), height: 18,
), child: CircularProgressIndicator(strokeWidth: 2),
child: Row( )
children: [ : const Icon(Icons.send),
CircleAvatar( label: Text(_loading
backgroundColor: _recording ? 'Sending...'
? const Color(0xFFFEE2E2) : _voiceMode
: const Color(0xFFEFF6FF), ? 'Send Voice Message'
child: Icon( : 'Send Message'),
_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),
),
),
],
),
), ),
], ],
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/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.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 '../../core/storage/secure_storage.dart';
import '../../shared/widgets/feature_page.dart'; import '../../shared/widgets/feature_page.dart';
@ -49,23 +52,27 @@ class GuardianSettingsScreen extends StatelessWidget {
subtitle: 'Account, pairing, AI tools, and server', subtitle: 'Account, pairing, AI tools, and server',
child: ListView( child: ListView(
children: [ children: [
_SettingsTile( StaggerWrapper(
icon: Icons.link, children: [
title: 'Pair User', _SettingsTile(
subtitle: 'Masukkan Pairing Code User atau cek status pairing.', icon: Icons.link,
onTap: () => context.go('/guardian/pairing'), title: 'Pair User',
), subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
_SettingsTile( onTap: () => context.go('/guardian/pairing'),
icon: Icons.speed, ),
title: 'AI Benchmark', _SettingsTile(
subtitle: 'Catat capture, inference, notification, dan TTS.', icon: Icons.speed,
onTap: () => context.go('/guardian/benchmark'), title: 'AI Benchmark',
), subtitle: 'Catat capture, inference, notification, dan TTS.',
_SettingsTile( onTap: () => context.go('/guardian/benchmark'),
icon: Icons.tune, ),
title: 'AI Config', _SettingsTile(
subtitle: 'Atur threshold deteksi dan label yang aktif.', icon: Icons.tune,
onTap: () => context.go('/guardian/ai-config'), title: 'AI Config',
subtitle: 'Atur threshold deteksi dan label yang aktif.',
onTap: () => context.go('/guardian/ai-config'),
),
],
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
OutlinedButton.icon( OutlinedButton.icon(
@ -103,19 +110,28 @@ class _SettingsTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return BounceTap(
margin: const EdgeInsets.only(bottom: 10), onTap: onTap,
decoration: BoxDecoration( child: Container(
color: Colors.white, margin: const EdgeInsets.only(bottom: 10),
borderRadius: BorderRadius.circular(16), decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE2E8F0)), color: AppColors.cardWhite,
), borderRadius: AppDecorations.cardRadius,
child: ListTile( border: Border.all(color: AppColors.border),
leading: Icon(icon, color: const Color(0xFF1D4ED8)), boxShadow: AppDecorations.cardShadow,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)), ),
subtitle: Text(subtitle), child: ListTile(
trailing: const Icon(Icons.chevron_right), leading: Container(
onTap: onTap, 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, context: context,
builder: (dialogContext) => StatefulBuilder( builder: (dialogContext) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog( builder: (context, setDialogState) => AlertDialog(
title: Text(_labelFromKey(item['commandKey']?.toString() ?? '') ?? title: Text(
'Voice Command'), _labelFromKey(item['commandKey']?.toString() ?? '') ??
'Voice Command',
),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -180,11 +182,13 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext, false), onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')), child: const Text('Cancel'),
),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save')), child: const Text('Save'),
),
], ],
), ),
), ),
@ -209,8 +213,9 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
context: context, context: context,
builder: (dialogContext) => StatefulBuilder( builder: (dialogContext) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog( builder: (context, setDialogState) => AlertDialog(
title: Text(_labelFromKey(item['shortcutKey']?.toString() ?? '') ?? title: Text(
'Shortcut'), _labelFromKey(item['shortcutKey']?.toString() ?? '') ?? 'Shortcut',
),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -230,6 +235,31 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
prefixIcon: Icon(Icons.numbers_outlined), 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( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
value: enabled, value: enabled,
@ -240,11 +270,13 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext, false), onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')), child: const Text('Cancel'),
),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save')), child: const Text('Save'),
),
], ],
), ),
), ),
@ -273,8 +305,9 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
}, },
onError: (message) { onError: (message) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context) ScaffoldMessenger.of(
.showSnackBar(SnackBar(content: Text(message))); context,
).showSnackBar(SnackBar(content: Text(message)));
}, },
fallback: 'Konfigurasi belum bisa disimpan.', fallback: 'Konfigurasi belum bisa disimpan.',
); );
@ -301,18 +334,15 @@ class _EndpointCard extends StatelessWidget {
'', '',
) ?? ) ??
'Item #${item['id'] ?? '-'}'; 'Item #${item['id'] ?? '-'}';
final subtitle = _firstText( final subtitle = _firstText(item, [
item, 'triggerPhrase',
[ 'buttonName',
'triggerPhrase', 'description',
'buttonName', 'action',
'description', 'shortcut',
'action', 'status',
'shortcut', 'createdAt',
'status', ]) ??
'createdAt'
],
) ??
'Data aktif'; 'Data aktif';
final enabled = item['enabled'] != false; final enabled = item['enabled'] != false;
return Container( return Container(
@ -338,11 +368,15 @@ class _EndpointCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, Text(
style: const TextStyle(fontWeight: FontWeight.w800)), title,
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 3), const SizedBox(height: 3),
Text(subtitle, Text(
style: const TextStyle(color: Color(0xFF64748B))), subtitle,
style: const TextStyle(color: Color(0xFF64748B)),
),
const SizedBox(height: 6), const SizedBox(height: 6),
Wrap( Wrap(
spacing: 8, spacing: 8,
@ -415,7 +449,6 @@ String? _labelFromKey(String value) {
return value return value
.split('_') .split('_')
.where((part) => part.isNotEmpty) .where((part) => part.isNotEmpty)
.map((part) => .map((part) => part[0].toUpperCase() + part.substring(1).toLowerCase())
part[0].toUpperCase() + part.substring(1).toLowerCase())
.join(' '); .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 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_text_styles.dart';
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
final String role; final String role;
@ -7,14 +10,26 @@ class HomeScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return DecoratedBox(
appBar: AppBar(title: const Text('Dashboard Walk Guide')), decoration: const BoxDecoration(
body: Center( gradient: LinearGradient(
child: Text( colors: [AppColors.softBlueBg, Colors.white],
role == 'ROLE_ADMIN' ? 'Selamat Datang Admin!' : 'Mode Walk Guide Siap!', begin: Alignment.topCenter,
style: const TextStyle(fontSize: 24), 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 'package:camera/camera.dart';
import '../../../core/secure_storage.dart'; import '../../../core/secure_storage.dart';
import '../../auth/presentation/login_screen.dart'; import '../../auth/presentation/login_screen.dart';
import '../../../../main.dart';
class UserDashboardScreen extends StatefulWidget { class UserDashboardScreen extends StatefulWidget {
const UserDashboardScreen({super.key}); const UserDashboardScreen({super.key});
@ -12,7 +11,8 @@ class UserDashboardScreen extends StatefulWidget {
State<UserDashboardScreen> createState() => _UserDashboardScreenState(); State<UserDashboardScreen> createState() => _UserDashboardScreenState();
} }
class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerProviderStateMixin { class _UserDashboardScreenState extends State<UserDashboardScreen>
with TickerProviderStateMixin {
CameraController? _camCtrl; CameraController? _camCtrl;
late AnimationController _radarCtrl; late AnimationController _radarCtrl;
late Animation<double> _radarAnim; late Animation<double> _radarAnim;
@ -31,8 +31,10 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
} }
Future<void> _initCamera() async { Future<void> _initCamera() async {
final cameras = await availableCameras();
if (cameras.isEmpty) return; if (cameras.isEmpty) return;
_camCtrl = CameraController(cameras[0], ResolutionPreset.high, enableAudio: false); _camCtrl = CameraController(cameras[0], ResolutionPreset.medium,
enableAudio: false);
await _camCtrl!.initialize(); await _camCtrl!.initialize();
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
@ -85,7 +87,8 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
gradient: RadialGradient( gradient: RadialGradient(
colors: [ colors: [
Colors.transparent, 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], stops: const [0.5, 1.0],
radius: 1.4, radius: 1.4,
@ -127,7 +130,8 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
), ),
child: Row(children: [ child: Row(children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.54), color: Colors.black.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@ -158,7 +162,8 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
const Spacer(), const Spacer(),
IconButton( IconButton(
onPressed: _logout, 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( style: IconButton.styleFrom(
backgroundColor: Colors.redAccent.withValues(alpha: 0.8), backgroundColor: Colors.redAccent.withValues(alpha: 0.8),
), ),
@ -204,15 +209,19 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
color: const Color(0x33F59E0B), color: const Color(0x33F59E0B),
borderRadius: BorderRadius.circular(7), 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), const SizedBox(width: 10),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Obstacle ahead', Text('Obstacle ahead',
style: GoogleFonts.inter( 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', 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: [ child: Column(children: [
Row(children: [ Row(children: [
Expanded(child: _bigBtn(const Color(0xCC1A56DB), Icons.mic, () {})), Expanded(
child: _bigBtn(const Color(0xCC1A56DB), Icons.mic, () {})),
const SizedBox(width: 12), 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), const SizedBox(height: 8),
Text( Text(
@ -290,7 +302,8 @@ class _RadarPainter extends CustomPainter {
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 1.2; ..strokeWidth = 1.2;
for (final r in [48.0, 34.0, 20.0]) { 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); canvas.drawCircle(center, r, paint);
} }
paint paint
@ -301,4 +314,4 @@ class _RadarPainter extends CustomPainter {
@override @override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false; 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 'package:flutter/material.dart';
import '../../core/services/voice_command_handler.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 { class ManualScreen extends StatelessWidget {
const ManualScreen({super.key}); const ManualScreen({super.key});
@ -8,16 +12,38 @@ class ManualScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final commands = VoiceCommandKey.values.map((key) => key.name).toList(); final commands = VoiceCommandKey.values.map((key) => key.name).toList();
return Scaffold( return FeaturePage(
appBar: AppBar(title: const Text('Manual')), title: 'Manual',
body: ListView.separated( subtitle: 'Voice command yang tersedia',
padding: const EdgeInsets.all(16), child: ListView(
itemCount: commands.length, children: [
separatorBuilder: (_, __) => const Divider(height: 1), StaggerWrapper(
itemBuilder: (context, index) => ListTile( children: [
leading: const Icon(Icons.record_voice_over), for (final command in commands)
title: Text(commands[index]), 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