Compare commits
6 Commits
a629357e8c
...
d2b3534dde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2b3534dde | ||
| 1110e5a42d | |||
|
|
6272ece15d | ||
|
|
66da2473e1 | ||
|
|
c6d1e01023 | ||
| 3cb32a4d69 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -40,8 +40,13 @@ build/
|
||||
|
||||
.env
|
||||
*.env
|
||||
walkguide-backend/demo/secrets.properties
|
||||
|
||||
walkguide-backend/demo/hs_err_pid*.log
|
||||
walkguide-backend/demo/backend-run*.log
|
||||
walkguide-backend/demo/src/main/resources/firebase/*.json
|
||||
walkguide-mobile/walkguide_app/android/app/google-services*.json
|
||||
walkguide-mobile/walkguide_app/ios/Runner/GoogleService-Info.plist
|
||||
|
||||
# Android SDK path (generated by Android Studio)
|
||||
walkguide-mobile/walkguide_app/android/local.properties
|
||||
|
||||
790
README.md
790
README.md
@ -1,489 +1,517 @@
|
||||
<div align="center">
|
||||
|
||||
> ⚠️ **WORK IN PROGRESS (WIP)** ⚠️
|
||||
> *This repository is currently under active development. All code, data, and structures are subject to continuous changes.*
|
||||
|
||||
<br>
|
||||
|
||||
<img src="assets/gambar.png" alt="WalkGuide Banner" width="720"/>
|
||||
|
||||
# WalkGuide: AI-Powered Navigation
|
||||
|
||||
**Integrated Mobile Application Project**
|
||||
*Flutter Mobile Frontend × Spring Boot Backend × OOAD*
|
||||
Integrated Mobile Application Project
|
||||
Flutter Mobile Frontend x Spring Boot Backend x OOAD
|
||||
|
||||
### Group Members
|
||||
WalkGuide is an accessibility-focused mobile system for visually impaired users and their guardians. The User app provides camera-based obstacle awareness, voice/TTS interaction, SOS, notifications, navigation, and call flows. The Guardian app provides monitoring, live location, SOS handling, remote configuration, notification sending, voice notes, and pairing management.
|
||||
|
||||
## Group Members
|
||||
|
||||
| Name | NIM | Responsibility |
|
||||
|------|-----|---------------|
|
||||
| Bambang Herlambang | 5803024019 | - |
|
||||
| Jap Robertus | 5803024004 | - |
|
||||
| Evan William | 5803024001 | Backend Engineer (Spring Boot API & Flutter) |
|
||||
|---|---:|---|
|
||||
| Bambang Herlambang | 5803024019 | Mobile feature support, documentation, testing |
|
||||
| Jap Robertus | 5803024004 | Mobile feature support, documentation, testing |
|
||||
| Evan William | 5803024001 | Backend API, Flutter integration, architecture alignment |
|
||||
|
||||
[](https://flutter.dev/)
|
||||
[](https://spring.io/projects/spring-boot)
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://firebase.google.com/)
|
||||
[](https://www.agora.io/)
|
||||
[](LICENSE)
|
||||
## Project Status
|
||||
|
||||

|
||||

|
||||
This repository is no longer a backend-only skeleton. The current codebase contains:
|
||||
|
||||
[**System Architecture**](#system-architecture) · [**Tech Stack**](#tech-stack) · [**Implementations**](#implementations) · [**API Endpoints**](#api-endpoints) · [**Design Patterns**](#design-patterns) · [**Results**](#results) · [**Weekly Progress**](#weekly-progress)
|
||||
- Spring Boot backend with JWT/RBAC, Flyway migrations V1-V17, PostgreSQL, OpenAPI, WebSocket, FCM service hooks, Agora token/call notification flow, SOS acknowledge/resolve flow, pairing code flow, service/controller tests, Testcontainers setup, and k6 assets.
|
||||
- Flutter app with server connection screen, auth, role-based routing, User screens, Guardian screens, feature page UI shell, friendly error handling, offline queue/cache layer, voice note UI support, SOS handling UI, WalkGuide/YOLO support files, and Android/mobile-first dependencies.
|
||||
- OOAD documentation in `ooad-docs/`, including the 7 GoF design pattern PUML diagrams and traceability documentation.
|
||||
|
||||
</div>
|
||||
Primary demo target: Android APK connected to the Spring Boot backend.
|
||||
Chrome/web can be used for UI/debug flows, but camera, native AI, SQLite FFI, and mobile permissions are Android-first.
|
||||
|
||||
---
|
||||
## Overview
|
||||
|
||||
## Overview — WalkGuide System
|
||||
Core objective: build an accessible navigation assistant that can help visually impaired users move more safely while allowing a Guardian to monitor, configure, and respond to events in real time.
|
||||
|
||||
**Core Objective:** How can we build an ultra-low latency, accessible navigation system for visually impaired users while providing real-time oversight for their guardians?
|
||||
Important flows implemented or represented in the codebase:
|
||||
|
||||
This project implements a dual-interface mobile application. The system relies on **On-Device AI (TFLite / YOLOv8n)** to eliminate network latency during obstacle detection, paired with a robust **Spring Boot backend** for secure authentication, guardian-user pairing, real-time location tracking via WebSocket, and push notifications via Firebase FCM.
|
||||
|
||||
**Deployment:** Backend is deployed on a university server at `202.46.28.160`. The Flutter APK uses a dynamic server URL — no hardcoded addresses — allowing multi-device testing without rebuilding.
|
||||
|
||||
---
|
||||
- Register/Login with Guardian and User roles.
|
||||
- Pairing by generated pairing code / user identity flow.
|
||||
- Guardian dashboard and tools screens.
|
||||
- User SOS trigger, Guardian acknowledge and resolve.
|
||||
- Guardian text and voice-note style notification sending.
|
||||
- Notification read/read-all handling.
|
||||
- Location update and Guardian live map support.
|
||||
- AI configuration, voice command configuration, hardware shortcut configuration.
|
||||
- WalkGuide obstacle detection pipeline integration points.
|
||||
- Call token and call notification endpoints for Agora-style VoIP flow.
|
||||
|
||||
## System Architecture
|
||||
|
||||
The study follows a strict three-pillar enterprise structure:
|
||||
The project follows a feature-first architecture across backend, mobile, and OOAD documents.
|
||||
|
||||
**Pillar 1 — OOAD (Object-Oriented Analysis & Design):** Comprehensive modeling using Use Case, Class, Sequence, and ERD diagrams. The codebase strictly implements ≥ 7 GoF Design Patterns (Builder, Singleton, Facade, Repository/Proxy, Observer, Strategy, Chain of Responsibility).
|
||||
### Backend
|
||||
|
||||
**Pillar 2 — Flutter (Mobile Frontend):** Implements Clean Architecture (Domain, Data, Presentation layers) with BLoC for state management. Uses `Dio` with interceptors for secure HTTP communication. Server URL is dynamically configured via `SharedPreferences` on first launch.
|
||||
Backend follows layered Spring Boot architecture:
|
||||
|
||||
**Pillar 3 — Spring Boot (Backend API):** A layered architecture (Controller → Service → Repository) powered by Java 21. Features JWT-based Role-Based Access Control (RBAC), standardized `ApiResponse` envelopes, WebSocket (STOMP) for real-time data, and Firebase Admin SDK for push notifications.
|
||||
```text
|
||||
Controller -> Service -> Repository -> Entity -> PostgreSQL
|
||||
```
|
||||
|
||||
---
|
||||
Main backend concerns:
|
||||
|
||||
- `controller/`: REST API endpoints.
|
||||
- `service/`: application/business logic.
|
||||
- `repository/`: Spring Data JPA persistence contracts.
|
||||
- `entity/`: database-mapped entities.
|
||||
- `dto/request` and `dto/response`: API input/output contracts.
|
||||
- `security/`: JWT utility, JWT filter, custom user details service.
|
||||
- `config/`: Security, WebSocket, OpenAPI, Firebase/FCM, seeding.
|
||||
- `websocket/`: STOMP broadcasting helper.
|
||||
- `db/migration/`: Flyway schema migrations.
|
||||
|
||||
### Flutter
|
||||
|
||||
Flutter uses a feature-first layout. Several critical features now have domain/data/application/presentation structure, while compatibility wrapper screens remain for the existing app routes.
|
||||
|
||||
Main Flutter concerns:
|
||||
|
||||
- `app/`: app shell, router, dependency injection.
|
||||
- `core/`: API service/client, services, storage/cache, AI helpers, errors.
|
||||
- `features/`: auth, server connect, pairing, SOS, notifications, WalkGuide, activity log, navigation, settings, call, guardian dashboard/tools, manual, benchmark.
|
||||
- `shared/widgets/`: common UI shell and reusable feature page components.
|
||||
|
||||
### OOAD
|
||||
|
||||
The `ooad-docs/` folder contains traceability and diagrams, including:
|
||||
|
||||
- `01_Builder_Pattern.puml`
|
||||
- `02_Singleton_Pattern.puml`
|
||||
- `03_Facade_Pattern.puml`
|
||||
- `04_Repository_Proxy_Pattern.puml`
|
||||
- `05_Observer_Pattern.puml`
|
||||
- `06_Strategy_Pattern.puml`
|
||||
- `07_ChainOfResponsibility_Pattern.puml`
|
||||
- Use case, sequence, state, ERD, class, and component diagrams under `ooad-docs/diagrams/`.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend (Spring Boot)
|
||||
### Backend
|
||||
|
||||
| Library / Tool | Version | Purpose |
|
||||
| Tool / Library | Current Codebase | Purpose |
|
||||
|---|---|---|
|
||||
| Java | 21 | Primary language |
|
||||
| Spring Boot | 3.3.x | Main framework |
|
||||
| Spring Security | (bundled) | Auth + RBAC |
|
||||
| Spring Data JPA | (bundled) | ORM |
|
||||
| Spring WebSocket (STOMP) | (bundled) | Real-time location & notification push |
|
||||
| PostgreSQL Driver | (bundled) | DB connection — university server `202.46.28.160` |
|
||||
| Flyway | 10.x | Database schema migration |
|
||||
| JJWT | 0.11.5 | JWT access + refresh token |
|
||||
| Firebase Admin SDK | 9.x | FCM push notifications |
|
||||
| Agora RESTful API | - | Generate Agora RTC token for VoIP |
|
||||
| Springdoc OpenAPI | 2.3.0 | Swagger UI documentation |
|
||||
| Lombok | latest | Boilerplate reduction |
|
||||
| JUnit 5 + Mockito | (bundled) | Unit testing |
|
||||
| MockMvc + Testcontainers | 1.19.x | Integration testing with real PostgreSQL |
|
||||
| JaCoCo | 0.8.x | Code coverage (target ≥ 70%) |
|
||||
| Java | 21 | Backend language |
|
||||
| Spring Boot | 3.2.5 | Main backend framework |
|
||||
| Spring Security | Spring Boot managed | JWT auth and RBAC |
|
||||
| Spring Data JPA | Spring Boot managed | ORM and repositories |
|
||||
| Spring WebSocket | Spring Boot managed | STOMP realtime channels |
|
||||
| PostgreSQL Driver | Runtime dependency | University PostgreSQL database |
|
||||
| Flyway | Core + PostgreSQL module | Database migrations |
|
||||
| JJWT | 0.11.5 | JWT access token handling |
|
||||
| Springdoc OpenAPI | 2.3.0 | Swagger/OpenAPI docs |
|
||||
| Lombok | 1.18.36 | Builders and boilerplate reduction |
|
||||
| JUnit 5 / Mockito / MockMvc | Test dependencies | Unit and controller testing |
|
||||
| Testcontainers | 1.19.7 | PostgreSQL-backed integration tests |
|
||||
| JaCoCo | 0.8.11 | Coverage report |
|
||||
|
||||
### Flutter (Mobile)
|
||||
### Flutter
|
||||
|
||||
| Package | Version | Purpose |
|
||||
| Package | Current Codebase | Purpose |
|
||||
|---|---|---|
|
||||
| flutter_bloc | 8.x | State management (sole pattern) |
|
||||
| go_router | 14.x | Navigation + role-based route guards |
|
||||
| dio | 5.x | HTTP client with interceptors |
|
||||
| shared_preferences | 2.x | Persist dynamic server URL |
|
||||
| flutter_secure_storage | 10.x | Secure JWT token storage |
|
||||
| drift | 2.x | SQLite ORM for offline cache |
|
||||
| tflite_flutter | 0.10.x | Run YOLOv8n on-device |
|
||||
| camera | 0.10.x | Camera feed for YOLO inference |
|
||||
| flutter_tts | 4.x | Text-to-Speech (ID + EN) |
|
||||
| speech_to_text | 6.x | Always-listening voice commands |
|
||||
| agora_rtc_engine | 6.x | VoIP call Guardian ↔ User |
|
||||
| firebase_messaging | 14.x | FCM push notification receiver |
|
||||
| flutter_map | 6.x | OpenStreetMap (free, no API key) |
|
||||
| geolocator | 11.x | Real-time GPS |
|
||||
| stomp_dart_client | 2.x | WebSocket STOMP for live tracking |
|
||||
| get_it | 7.x | Service locator / dependency injection |
|
||||
| dartz | 0.10.x | `Either<Failure, Data>` typed error handling |
|
||||
| vibration | 1.x | Haptic feedback on obstacle detection |
|
||||
| flutter_bloc | 8.1.6 | Cubit/BLoC state management |
|
||||
| go_router | 14.2.7 | App routing |
|
||||
| dio | 5.4.3+1 | REST client |
|
||||
| flutter_secure_storage | 9.2.2 | Secure token storage on mobile |
|
||||
| shared_preferences | 2.3.2 | Server URL and web cache fallback |
|
||||
| drift / sqlite3 | drift 2.18.0, sqlite3 2.4.7 | Local persistence support |
|
||||
| sqlite3_flutter_libs | 0.5.24 | Android/iOS SQLite native libs |
|
||||
| camera | 0.11.0+2 | Camera feed |
|
||||
| tflite_flutter | 0.12.1 | On-device model inference |
|
||||
| flutter_tts | 4.0.2 | Text-to-speech |
|
||||
| speech_to_text | 7.0.0 | Voice recognition support |
|
||||
| firebase_core / firebase_messaging | 3.3.0 / 15.1.0 | FCM integration |
|
||||
| flutter_local_notifications | 17.2.1+2 | Foreground/local notification UI |
|
||||
| flutter_map / latlong2 | 7.0.2 / 0.9.1 | OpenStreetMap UI |
|
||||
| geolocator | 12.0.0 | GPS/location |
|
||||
| agora_rtc_engine | 6.3.2 | VoIP engine integration |
|
||||
| just_audio / record | 0.9.40 / 5.1.2 | Voice note playback/recording |
|
||||
| get_it | 8.0.2 | Dependency injection |
|
||||
| dartz | 0.10.1 | Either-style error handling |
|
||||
| connectivity_plus | 6.0.3 | Offline/online detection |
|
||||
|
||||
### External Services
|
||||
## Runtime Configuration
|
||||
|
||||
| Service | Purpose | Cost |
|
||||
|---|---|---|
|
||||
| Firebase (FCM) | Push notifications | Free |
|
||||
| Agora RTC | In-app VoIP audio call | 10,000 min/month free |
|
||||
| University Server PostgreSQL | DB at `202.46.28.160:2002` | Free (managed by lecturer) |
|
||||
| OpenStreetMap + OSRM | Map tiles + turn-by-turn routing | Free |
|
||||
| YOLOv8n (Ultralytics) | Obstacle detection model (.tflite) | Free (open source) |
|
||||
### Backend
|
||||
|
||||
---
|
||||
Local/dev config imports an optional gitignored file at `walkguide-backend/demo/secrets.properties`.
|
||||
Copy `walkguide-backend/demo/secrets.properties.example` to `secrets.properties` and fill it locally.
|
||||
Tracked config files do not contain DB passwords, JWT secrets, Agora certificates, or Firebase keys.
|
||||
|
||||
Both dev and production expect these values from environment variables or `secrets.properties`:
|
||||
|
||||
```text
|
||||
DB_URL
|
||||
DB_USERNAME
|
||||
DB_PASSWORD
|
||||
JWT_SECRET
|
||||
AGORA_APP_ID
|
||||
AGORA_APP_CERTIFICATE
|
||||
```
|
||||
|
||||
### Flutter
|
||||
|
||||
The Flutter app uses a dynamic server URL. On first launch, use the Server Connect screen and enter a backend URL such as:
|
||||
|
||||
```text
|
||||
http://202.46.28.170:8080
|
||||
```
|
||||
|
||||
For testing on a physical phone against a backend running on a laptop, do not use `localhost`. Use the laptop LAN IP:
|
||||
|
||||
```text
|
||||
http://192.168.x.x:8080
|
||||
```
|
||||
|
||||
## Implementations
|
||||
|
||||
### Design A — User Mode (Visually Impaired)
|
||||
An accessibility-first interface that opens directly to an active camera view.
|
||||
- **On-Device AI:** YOLOv8n obstacle detection via TFLite runs locally — zero network latency. Detects 80 COCO object classes, reports direction (LEFT/CENTER/RIGHT) and estimated distance.
|
||||
- **Voice Commands:** 14 configurable voice commands (e.g., "Start Walkguide", "Call Guardian", "Send SOS", "Where Am I"). Always-listening `SpeechToText` with auto-restart.
|
||||
- **Hardware Mapping:** Physical volume buttons mapped to critical actions (Vol Up = Call Guardian, Vol Down = Start WalkGuide by default).
|
||||
- **TTS Feedback:** Queued and immediate Text-to-Speech for obstacle alerts, navigation, and screen announcements. Supports Bahasa Indonesia and English.
|
||||
- **SOS System:** One-command emergency alert triggers high-priority FCM to Guardian with GPS coordinates.
|
||||
### User Mode
|
||||
|
||||
### Design B — Guardian Mode (Admin/Caregiver)
|
||||
A command center dashboard for oversight and remote configuration.
|
||||
- **Real-time Live Map:** Tracks User's GPS location via WebSocket STOMP subscription, rendered on OpenStreetMap with `flutter_map`. Includes geofence circle overlay.
|
||||
- **Remote AI Configuration:** Adjusts YOLO confidence threshold, alert distance thresholds, max inference FPS, and enabled object labels.
|
||||
- **Voice Command Management:** Edits trigger phrases and enables/disables any of the 14 voice commands remotely.
|
||||
- **Notifications:** Sends text messages or voice notes (recorded audio) directly to the User's device.
|
||||
- **Geofence:** Sets a geographic boundary — backend notifies Guardian via FCM when User exits.
|
||||
- WalkGuide camera screen and AI pipeline integration points.
|
||||
- TTS feedback and friendly error handling.
|
||||
- SOS screen with emergency action flow.
|
||||
- Notifications screen with read/read-all actions.
|
||||
- Activity log, navigation, settings, pairing, and manual screens.
|
||||
- Voice command and shortcut configuration retrieval paths.
|
||||
- Offline queue/cache support for core app data.
|
||||
|
||||
---
|
||||
### Guardian Mode
|
||||
|
||||
- Guardian dashboard and tools.
|
||||
- Guardian live map screen.
|
||||
- Guardian activity log screen.
|
||||
- Send notification screen with text and voice note modes.
|
||||
- AI config screen.
|
||||
- Voice command and shortcut management screens.
|
||||
- Geofence/settings screens.
|
||||
- SOS acknowledge and resolve support.
|
||||
|
||||
### Backend API
|
||||
|
||||
- Auth, pairing, user, guardian, and shared call controllers.
|
||||
- Service layer for pairing, location, activity, obstacles, notifications, SOS, AI config, voice commands, hardware shortcuts, geofence, user settings, call notifications, and dashboard aggregation.
|
||||
- WebSocket broadcaster for location/SOS/notification-style real-time updates.
|
||||
- Flyway-managed PostgreSQL schema.
|
||||
|
||||
## Database Schema
|
||||
|
||||
Database runs on PostgreSQL at `202.46.28.160:2002`. Schema is managed exclusively by Flyway migrations. `spring.jpa.hibernate.ddl-auto=validate`.
|
||||
Database: PostgreSQL on the university server.
|
||||
Schema management: Flyway.
|
||||
Hibernate mode: `validate`.
|
||||
|
||||
| Migration | Table | Status |
|
||||
Current migrations in `walkguide-backend/demo/src/main/resources/db/migration/`:
|
||||
|
||||
| Migration | File | Status |
|
||||
|---|---|---|
|
||||
| V1 | `users` | ✅ Exists |
|
||||
| V2 | Seed data | ✅ Exists |
|
||||
| V3 | Guardian-User link | ✅ Exists |
|
||||
| V4 | Add `unique_user_id` column | 🔄 Needs creation |
|
||||
| V5 | `pairing_relations` | 🔄 Needs creation |
|
||||
| V6 | `activity_logs` | 🔄 Needs creation |
|
||||
| V7 | `obstacle_logs` | 🔄 Needs creation |
|
||||
| V8 | `location_history` | 🔄 Needs creation |
|
||||
| V9 | `guardian_notifications` | 🔄 Needs creation |
|
||||
| V10 | `sos_events` | 🔄 Needs creation |
|
||||
| V11 | `user_settings` | 🔄 Needs creation |
|
||||
| V12 | `ai_configs` | 🔄 Needs creation |
|
||||
| V13 | `voice_command_configs` | 🔄 Needs creation |
|
||||
| V14 | `hardware_shortcuts` | 🔄 Needs creation |
|
||||
| V15 | `geofence_configs` | 🔄 Needs creation |
|
||||
| V16 | `refresh_tokens` | 🔄 Needs creation |
|
||||
|
||||
---
|
||||
| V1 | `V1__create_users_table.sql` | Present |
|
||||
| V2 | `V2__seed_users.sql` | Present |
|
||||
| V3 | `V3__link_guardian_user.sql` | Present |
|
||||
| V4 | `V4__alter_users_add_columns.sql` | Present |
|
||||
| V5 | `V5__create_pairing_relations.sql` | Present |
|
||||
| V6 | `V6__create_activity_logs.sql` | Present |
|
||||
| V7 | `V7__create_obstacle_logs.sql` | Present |
|
||||
| V8 | `V8__create_location_history.sql` | Present |
|
||||
| V9 | `V9__create_guardian_notifications.sql` | Present |
|
||||
| V10 | `V10__create_sos_events.sql` | Present |
|
||||
| V11 | `V11__create_user_settings.sql` | Present |
|
||||
| V12 | `V12__create_ai_configs.sql` | Present |
|
||||
| V13 | `V13__create_voice_command_configs.sql` | Present |
|
||||
| V14 | `V14__create_hardware_shortcuts.sql` | Present |
|
||||
| V15 | `V15__create_geofence_configs.sql` | Present |
|
||||
| V16 | `V16__create_refresh_tokens.sql` | Present |
|
||||
| V17 | `V17__add_expiring_pairing_codes.sql` | Present |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
26 REST endpoints across 5 groups (exam minimum: 10).
|
||||
|
||||
**Auth** — `/api/v1/auth`
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/ping` | None | Health check / server connection test |
|
||||
| POST | `/register` | None | Register Guardian or User |
|
||||
| POST | `/login` | None | Login, returns access + refresh token |
|
||||
| POST | `/refresh` | None | Refresh access token |
|
||||
| POST | `/logout` | ✅ | Logout, invalidate refresh token |
|
||||
| PUT | `/fcm-token` | ✅ | Update FCM device token |
|
||||
|
||||
**Pairing** — `/api/v1/shared/pairing`
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/invite` | GUARDIAN | Invite User by 12-char unique ID |
|
||||
| POST | `/respond` | USER | Accept or reject pairing invite |
|
||||
| DELETE | `/unpair` | ✅ | Dissolve active pairing |
|
||||
| GET | `/status` | ✅ | Get current pairing status |
|
||||
|
||||
**Guardian** — `/api/v1/guardian`
|
||||
### Auth - `/api/v1/auth`
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/dashboard` | Combined home data (user status, recent logs, SOS count) |
|
||||
| GET | `/user-status` | Full status of paired User |
|
||||
| GET | `/user-location` | Last known GPS location |
|
||||
| GET | `/ping` | Server connection check |
|
||||
| POST | `/register` | Register Guardian or User |
|
||||
| POST | `/login` | Login |
|
||||
| POST | `/refresh` | Refresh access token |
|
||||
| POST | `/logout` | Logout |
|
||||
| PUT | `/fcm-token` | Update FCM token |
|
||||
|
||||
### Pairing - `/api/v1/shared/pairing`
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/code` | Get current pairing code |
|
||||
| POST | `/code/regenerate` | Regenerate pairing code |
|
||||
| POST | `/invite` | Guardian invites User |
|
||||
| POST | `/respond` | User accepts/rejects invite |
|
||||
| DELETE | `/unpair` | Remove pairing |
|
||||
| GET | `/status` | Get current pairing status |
|
||||
|
||||
### Guardian - `/api/v1/guardian`
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/dashboard` | Guardian home data |
|
||||
| GET | `/user-location` | Last known User location |
|
||||
| GET | `/location-history` | Paginated location history |
|
||||
| GET | `/activity-logs` | Paginated activity logs of paired User |
|
||||
| GET | `/obstacle-logs` | Paginated obstacle detection logs |
|
||||
| POST | `/notifications/send` | Send text or voice note to User |
|
||||
| GET | `/activity-logs` | Paginated User activity logs |
|
||||
| GET | `/obstacle-logs` | Paginated obstacle logs |
|
||||
| POST | `/notifications/send` | Send text or voice note notification |
|
||||
| GET | `/sos-events` | Paginated SOS events |
|
||||
| PUT | `/sos/{id}/acknowledge` | Acknowledge SOS alert |
|
||||
| GET/PUT | `/ai-config` | Get or update AI configuration |
|
||||
| GET/PUT | `/voice-commands` | Get or update voice command configs |
|
||||
| GET/PUT | `/shortcuts` | Get or update hardware shortcut configs |
|
||||
| GET/PUT | `/geofence` | Get or update geofence config |
|
||||
| PUT | `/sos/{id}/acknowledge` | Mark SOS as acknowledged |
|
||||
| PUT | `/sos/{id}/resolve` | Mark SOS as resolved/handled |
|
||||
| GET/PUT | `/ai-config` | Get/update AI config |
|
||||
| GET/PUT | `/voice-commands` | Get/update voice command config |
|
||||
| GET/PUT | `/shortcuts` | Get/update shortcut config |
|
||||
| GET/PUT | `/geofence` | Get/update geofence config |
|
||||
| GET/PUT | `/user-settings` | Get/update paired User settings |
|
||||
|
||||
**User** — `/api/v1/user`
|
||||
### User - `/api/v1/user`
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/profile` | User profile (id, email, displayName, uniqueUserId) |
|
||||
| GET/PUT | `/settings` | Get or update TTS/haptic settings |
|
||||
| GET | `/voice-commands` | Get all voice commands (read-only) |
|
||||
| GET/PUT | `/shortcuts` | Get or capture hardware shortcut assignments |
|
||||
| GET | `/ai-config` | Get AI config (read-only) |
|
||||
| POST | `/location` | Send GPS update |
|
||||
| POST | `/obstacle` | Log detected obstacle |
|
||||
| POST | `/sos` | Trigger SOS alert |
|
||||
| GET | `/activity-logs` | Paginated own activity log |
|
||||
| GET | `/notifications` | Paginated notifications from Guardian |
|
||||
| GET | `/notifications/unread-count` | Unread notification count |
|
||||
| PUT | `/notifications/mark-all-read` | Mark all as read |
|
||||
| PUT | `/notifications/{id}/read` | Mark one as read |
|
||||
| POST | `/walkguide/start` | Log WalkGuide session start |
|
||||
| POST | `/walkguide/stop` | Log WalkGuide session stop |
|
||||
| GET | `/profile` | Current User profile |
|
||||
| GET/PUT | `/settings` | Get/update User settings |
|
||||
| GET | `/voice-commands` | Get voice command config |
|
||||
| GET/PUT | `/shortcuts` | Get/update shortcut config |
|
||||
| GET | `/ai-config` | Get AI config |
|
||||
| POST | `/location` | Send location update |
|
||||
| POST | `/obstacle` | Log obstacle |
|
||||
| POST | `/sos` | Trigger SOS |
|
||||
| GET | `/sos-events` | Get own SOS events |
|
||||
| GET | `/activity-logs` | Get activity logs |
|
||||
| GET | `/notifications` | Get notifications |
|
||||
| GET | `/notifications/unread-count` | Get unread count |
|
||||
| PUT | `/notifications/mark-all-read` | Mark all notifications read |
|
||||
| PUT | `/notifications/{id}/read` | Mark one notification read |
|
||||
| POST | `/walkguide/start` | Log WalkGuide start |
|
||||
| POST | `/walkguide/stop` | Log WalkGuide stop |
|
||||
|
||||
**Shared Call** — `/api/v1/shared/call`
|
||||
### Shared Call - `/api/v1/shared/call`
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/token` | Generate Agora RTC token |
|
||||
| POST | `/notify` | Send "Incoming Call" FCM to other party |
|
||||
|
||||
---
|
||||
| POST | `/token` | Generate call token/channel payload |
|
||||
| POST | `/notify` | Notify other party of incoming call |
|
||||
| POST | `/end` | Notify/end call session |
|
||||
|
||||
## Design Patterns
|
||||
|
||||
7 GoF Design Patterns implemented (exam minimum: 4, minimum 1 per category).
|
||||
The project documents and maps 7 GoF-related patterns in `ooad-docs/`.
|
||||
|
||||
| # | Category | Pattern | Location |
|
||||
| # | Category | Pattern | Main Location |
|
||||
|---|---|---|---|
|
||||
| 1 | Creational | **Builder** | `User.java` (`@Builder`), `FcmService` message construction |
|
||||
| 2 | Creational | **Singleton** | Flutter: `TtsService`, `YoloDetector`, `WebSocketService`, `AgoraService` via GetIt |
|
||||
| 3 | Structural | **Facade** | Flutter: `VoiceCommandHandler`; Backend: `GuardianDashboardService` |
|
||||
| 4 | Structural | **Repository (Proxy)** | All `*_repository_impl.dart` — proxy between domain and data sources |
|
||||
| 5 | Behavioral | **Observer** | BLoC pattern (BLoC = Subject, Widgets = Observers); WebSocket callbacks |
|
||||
| 6 | Behavioral | **Strategy** | `ObstacleAnalyzer` direction strategy; `ObstacleAlertStrategyService` (backend) |
|
||||
| 7 | Behavioral | **Chain of Responsibility** | Spring Security filter chain; Dio interceptor chain |
|
||||
| 1 | Creational | Builder | Backend entities/DTO construction, Lombok builders |
|
||||
| 2 | Creational | Singleton | Flutter services registered through GetIt |
|
||||
| 3 | Structural | Facade | Guardian dashboard aggregation and voice command/service coordination |
|
||||
| 4 | Structural | Repository / Proxy | Spring Data repositories and Flutter repository-style data access |
|
||||
| 5 | Behavioral | Observer | BLoC/Cubit state listeners and WebSocket callbacks |
|
||||
| 6 | Behavioral | Strategy | Obstacle analysis / alert behavior mapping documented in OOAD |
|
||||
| 7 | Behavioral | Chain of Responsibility | Spring Security filter chain and Dio interceptors |
|
||||
|
||||
---
|
||||
See:
|
||||
|
||||
## Metrics
|
||||
- `ooad-docs/DESIGN_PATTERNS.md`
|
||||
- `ooad-docs/TRACEABILITY_AUDIT.md`
|
||||
- `ooad-docs/01_Builder_Pattern.puml` through `ooad-docs/07_ChainOfResponsibility_Pattern.puml`
|
||||
|
||||
| Metric | Instrument | Target |
|
||||
|---|---|---|
|
||||
| API Throughput | Apache JMeter / k6 | ≥ 100 req/s under load |
|
||||
| API p95 Latency | Apache JMeter / k6 | < 500ms |
|
||||
| UI Frame Rate | Flutter DevTools | ≥ 90% frames < 16ms |
|
||||
| Code Coverage | JaCoCo | ≥ 70% (Service & Controller) |
|
||||
| APK Size | `flutter build apk --analyze-size` | < 50MB |
|
||||
## Metrics And Evidence
|
||||
|
||||
---
|
||||
The repository includes testing and benchmark support, but final scoring evidence should be generated on the target machine/device before submission.
|
||||
|
||||
| Area | Current Support |
|
||||
|---|---|
|
||||
| Backend unit tests | JUnit/Mockito tests under `src/test/java` |
|
||||
| Backend controller/integration tests | MockMvc and Testcontainers setup present |
|
||||
| Coverage | JaCoCo configured in Maven |
|
||||
| Load testing | k6 assets/results folder present under backend project |
|
||||
| Flutter tests | Unit/widget/integration test files present |
|
||||
| Flutter performance | Benchmark evidence template in `ooad-docs/BENCHMARK_EVIDENCE_TEMPLATE.md` |
|
||||
|
||||
Recommended final evidence:
|
||||
|
||||
- `mvn test` or `mvn verify` output.
|
||||
- JaCoCo HTML report.
|
||||
- Testcontainers run on a Docker-enabled machine.
|
||||
- k6 result at 50 or more concurrent virtual users.
|
||||
- Flutter physical Android profile evidence: cold start, memory, jank/frame timing, CPU during AI, API latency, APK size.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
> 🚨 **DISCLAIMER:** This repository is in active development. The directory tree below reflects the intended final architecture. Flutter implementation is currently pending — backend (Spring Boot) is the active development phase.
|
||||
|
||||
```text
|
||||
/
|
||||
├── walkguide-backend/ # [Spring Boot Engine — ACTIVE]
|
||||
│ ├── src/main/java/com/walkguide/
|
||||
│ │ ├── config/ # SecurityConfig, WebSocketConfig, OpenApiConfig, FcmConfig, DataSeeder
|
||||
│ │ ├── enums/ # UserRole, PairingStatus, ActivityLogType, VoiceCommandKey,
|
||||
│ │ │ # HardwareShortcutKey, NotificationType, SosStatus
|
||||
│ │ ├── entity/ # User, PairingRelation, ActivityLog, ObstacleLog,
|
||||
│ │ │ # LocationHistory, GuardianNotification, SosEvent,
|
||||
│ │ │ # UserSettings, AiConfig, VoiceCommandConfig,
|
||||
│ │ │ # HardwareShortcut, GeofenceConfig, RefreshToken
|
||||
│ │ ├── repository/ # JPA repositories for all entities
|
||||
│ │ ├── dto/
|
||||
│ │ │ ├── request/ # RegisterRequest, LoginRequest, InviteUserRequest,
|
||||
│ │ │ │ # LocationUpdateRequest, ObstacleLogRequest,
|
||||
│ │ │ │ # SendNotificationRequest, SosRequest,
|
||||
│ │ │ │ # AiConfigUpdateRequest, VoiceCommandUpdateRequest,
|
||||
│ │ │ │ # HardwareShortcutUpdateRequest, GeofenceConfigRequest,
|
||||
│ │ │ │ # UserSettingsUpdateRequest, and more
|
||||
│ │ │ └── response/ # ApiResponse (standard wrapper), AuthDataResponse,
|
||||
│ │ │ # UserProfileResponse, PairingStatusResponse,
|
||||
│ │ │ # ActivityLogResponse, ObstacleLogResponse,
|
||||
│ │ │ # LocationResponse, NotificationResponse,
|
||||
│ │ │ # SosEventResponse, AgoraTokenResponse,
|
||||
│ │ │ # AiConfigResponse, VoiceCommandResponse,
|
||||
│ │ │ # HardwareShortcutResponse, GeofenceResponse
|
||||
│ │ ├── service/ # AuthService, PairingService, ActivityLogService,
|
||||
│ │ │ # LocationService, ObstacleLogService,
|
||||
│ │ │ # NotificationService, SosService,
|
||||
│ │ │ # AiConfigService, VoiceCommandService,
|
||||
│ │ │ # HardwareShortcutService, GeofenceService,
|
||||
│ │ │ # UserSettingsService, FcmService,
|
||||
│ │ │ # AgoraTokenService, GuardianDashboardService
|
||||
│ │ ├── controller/ # AuthController, PairingController,
|
||||
│ │ │ # GuardianController, UserController, CallController
|
||||
│ │ ├── security/ # JwtUtil, JwtAuthFilter, CustomUserDetailsService
|
||||
│ │ ├── websocket/ # LocationBroadcaster (STOMP broadcaster)
|
||||
│ │ └── exception/ # GlobalExceptionHandler, ResourceNotFoundException,
|
||||
│ │ # UnauthorizedException, PairingException
|
||||
│ ├── src/main/resources/
|
||||
│ │ ├── application.properties # DB config (PostgreSQL 202.46.28.160:2002), Flyway, JWT
|
||||
│ │ ├── firebase/
|
||||
│ │ │ └── google-services-admin.json # FCM service account key (gitignored!)
|
||||
│ │ └── db/migration/
|
||||
│ │ ├── V1__create_users_table.sql ✅ Exists
|
||||
│ │ ├── V2__seed_users.sql ✅ Exists
|
||||
│ │ ├── V3__link_guardian_user.sql ✅ Exists
|
||||
│ │ ├── V4__add_unique_user_id.sql 🔄 Needs creation
|
||||
│ │ ├── V5__create_pairing_relations.sql 🔄 Needs creation
|
||||
│ │ ├── V6__create_activity_logs.sql 🔄 Needs creation
|
||||
│ │ ├── V7__create_obstacle_logs.sql 🔄 Needs creation
|
||||
│ │ ├── V8__create_location_history.sql 🔄 Needs creation
|
||||
│ │ ├── V9__create_notifications.sql 🔄 Needs creation
|
||||
│ │ ├── V10__create_sos_events.sql 🔄 Needs creation
|
||||
│ │ ├── V11__create_user_settings.sql 🔄 Needs creation
|
||||
│ │ ├── V12__create_ai_configs.sql 🔄 Needs creation
|
||||
│ │ ├── V13__create_voice_commands.sql 🔄 Needs creation
|
||||
│ │ ├── V14__create_hardware_shortcuts.sql 🔄 Needs creation
|
||||
│ │ ├── V15__create_geofence_configs.sql 🔄 Needs creation
|
||||
│ │ └── V16__create_refresh_tokens.sql 🔄 Needs creation
|
||||
│ └── src/test/java/com/walkguide/
|
||||
│ ├── service/ # AuthServiceTest, PairingServiceTest,
|
||||
│ │ # NotificationServiceTest, SosServiceTest,
|
||||
│ │ # LocationServiceTest, AiConfigServiceTest
|
||||
│ └── controller/ # AuthControllerTest, GuardianControllerTest,
|
||||
│ # UserControllerTest (MockMvc + Testcontainers)
|
||||
│
|
||||
├── walkguide-mobile/ # [Flutter Frontend — PENDING]
|
||||
│ ├── lib/
|
||||
│ │ ├── main.dart # Firebase init, GetIt setup, camera init
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── app.dart # MultiBlocProvider + MaterialApp.router
|
||||
│ │ │ ├── router.dart # GoRouter: /server-connect → /splash → role-based routes
|
||||
│ │ │ └── injection_container.dart # GetIt singletons & factories
|
||||
│ │ ├── core/
|
||||
│ │ │ ├── constants/ # app_constants.dart (dynamic BASE_URL via SharedPreferences),
|
||||
│ │ │ │ # route_constants.dart, voice_command_keys.dart
|
||||
│ │ │ ├── errors/ # failures.dart, exceptions.dart
|
||||
│ │ │ ├── network/ # api_client.dart (Dio), auth_interceptor.dart,
|
||||
│ │ │ │ # error_interceptor.dart, network_info.dart
|
||||
│ │ │ ├── storage/ # secure_storage.dart (JWT), local_database.dart (Drift)
|
||||
│ │ │ ├── services/ # tts_service.dart, stt_service.dart,
|
||||
│ │ │ │ # voice_command_handler.dart,
|
||||
│ │ │ │ # hardware_shortcut_listener.dart,
|
||||
│ │ │ │ # fcm_service.dart, haptic_service.dart,
|
||||
│ │ │ │ # websocket_service.dart, agora_service.dart
|
||||
│ │ │ ├── ai/ # model_loader.dart, yolo_detector.dart,
|
||||
│ │ │ │ # obstacle_analyzer.dart
|
||||
│ │ │ ├── theme/ # app_theme.dart, app_colors.dart
|
||||
│ │ │ └── utils/ # permission_manager.dart, location_service.dart
|
||||
│ │ ├── features/
|
||||
│ │ │ ├── server_connect/ # ServerConnectScreen — FIRST SCREEN on fresh install
|
||||
│ │ │ │ # BLoC: TestConnection, SaveAndContinue, ChangeServer
|
||||
│ │ │ ├── auth/ # SplashScreen, LoginScreen, RegisterScreen
|
||||
│ │ │ │ # Clean Arch: domain / data / presentation
|
||||
│ │ │ ├── pairing/ # UserPairingScreen (show unique ID, accept invite)
|
||||
│ │ │ │ # GuardianPairingScreen (invite by 12-char ID)
|
||||
│ │ │ ├── walk_guide/ # WalkGuideScreen (full-screen camera + YOLO overlay)
|
||||
│ │ │ │ # WalkGuideBloc: Start → camera stream → detect → TTS
|
||||
│ │ │ ├── sos/ # SosScreen (large SOS button)
|
||||
│ │ │ ├── activity_log/ # ActivityLogScreen (paginated, filterable)
|
||||
│ │ │ ├── notifications/ # NotificationScreen + TTS read-all feature
|
||||
│ │ │ ├── navigation_mode/ # NavigationModeScreen (OSM map + OSRM routing)
|
||||
│ │ │ ├── settings/ # UserSettingsScreen (TTS, pairing, account)
|
||||
│ │ │ ├── call/ # CallScreen + IncomingCallScreen (Agora VoIP)
|
||||
│ │ │ ├── manual/ # ManualScreen (all voice commands & shortcuts)
|
||||
│ │ │ └── guardian_dashboard/ # GuardianDashboardScreen, GuardianMapScreen,
|
||||
│ │ │ # GuardianActivityLogScreen, GuardianSendNotifScreen,
|
||||
│ │ │ # GuardianAiConfigScreen, GuardianVoiceCmdScreen,
|
||||
│ │ │ # GuardianShortcutScreen, GuardianGeofenceScreen
|
||||
│ │ └── shared/widgets/ # AppBottomNav, VoiceCommandListener,
|
||||
│ │ # TtsAwareScaffold, LoadingOverlay, ErrorWidget
|
||||
│ ├── assets/
|
||||
│ │ ├── models/
|
||||
│ │ │ ├── yolov8n.tflite # YOLOv8n converted to TFLite (~6MB)
|
||||
│ │ │ └── labels.txt # 80 COCO object labels
|
||||
│ │ └── images/
|
||||
│ └── pubspec.yaml
|
||||
│
|
||||
├── ooad-docs/ # [Design Artifacts]
|
||||
│ ├── diagrams/ # PlantUML / draw.io exports
|
||||
│ └── Traceability_Matrix.md
|
||||
│
|
||||
└── README.md
|
||||
|-- walkguide-backend/
|
||||
| |-- demo/
|
||||
| |-- src/main/java/com/walkguide/
|
||||
| | |-- config/
|
||||
| | |-- controller/
|
||||
| | |-- dto/
|
||||
| | |-- entity/
|
||||
| | |-- enums/
|
||||
| | |-- exception/
|
||||
| | |-- repository/
|
||||
| | |-- security/
|
||||
| | |-- service/
|
||||
| | `-- websocket/
|
||||
| |-- src/main/resources/
|
||||
| | |-- application.properties
|
||||
| | |-- application-dev.yml
|
||||
| | |-- application-prod.yml
|
||||
| | `-- db/migration/V1...V17
|
||||
| |-- src/test/java/com/walkguide/
|
||||
| |-- k6-tests/
|
||||
| `-- pom.xml
|
||||
|
|
||||
|-- walkguide-mobile/
|
||||
| `-- walkguide_app/
|
||||
| |-- lib/
|
||||
| | |-- app/
|
||||
| | |-- core/
|
||||
| | |-- features/
|
||||
| | `-- shared/
|
||||
| |-- assets/
|
||||
| | |-- images/
|
||||
| | `-- models/
|
||||
| |-- test/
|
||||
| |-- integration_test/
|
||||
| `-- pubspec.yaml
|
||||
|
|
||||
|-- ooad-docs/
|
||||
| |-- 01_Builder_Pattern.puml
|
||||
| |-- 02_Singleton_Pattern.puml
|
||||
| |-- 03_Facade_Pattern.puml
|
||||
| |-- 04_Repository_Proxy_Pattern.puml
|
||||
| |-- 05_Observer_Pattern.puml
|
||||
| |-- 06_Strategy_Pattern.puml
|
||||
| |-- 07_ChainOfResponsibility_Pattern.puml
|
||||
| `-- diagrams/
|
||||
|
|
||||
|-- FULL_FLOW_ARCHITECTURE.md
|
||||
|-- FINAL_EXAM_GUIDE.md
|
||||
|-- TODO.md
|
||||
`-- README.md
|
||||
```
|
||||
|
||||
---
|
||||
## Feature Flows
|
||||
|
||||
## Feature Flows (High-Level)
|
||||
### Flow 1 - Register And Pairing
|
||||
|
||||
### Flow 1 — Register & Pairing
|
||||
Guardian registers → User registers (gets a 12-char `uniqueUserId`) → Guardian enters User's ID → backend sends FCM invite → User accepts in app → backend seeds 14 default voice commands, 5 hardware shortcuts, and AI config for the pair.
|
||||
Guardian/User registers -> backend creates account and role data -> User receives pairing identity/code -> Guardian sends invite -> User responds -> backend activates pairing and seeds related configs.
|
||||
|
||||
### Flow 2 — WalkGuide Active Detection
|
||||
User says "Start Walkguide" (or presses Volume Down) → camera starts → frames are throttled to max inference FPS → YOLOv8n detects obstacles → direction and distance are analyzed → TTS announces "Caution! Person ahead center. Very close. Please stop." → haptic feedback triggers → obstacle logged to backend → GPS location broadcast via WebSocket to Guardian's live map every 5 seconds.
|
||||
### Flow 2 - WalkGuide Detection
|
||||
|
||||
### Flow 3 — VoIP Call
|
||||
User says "Call Guardian" → Agora token fetched from backend → FCM "Incoming Call" sent to Guardian → Guardian accepts in `IncomingCallScreen` → both join same Agora RTC channel → audio call connects.
|
||||
User starts WalkGuide -> camera/AI pipeline runs on device -> obstacle results are analyzed -> TTS/haptic feedback is emitted -> obstacle/location events can be logged to backend.
|
||||
|
||||
### Flow 4 — Guardian Sends Notification → User Hears via TTS
|
||||
Guardian sends text/voice note → backend saves notification + sends FCM → User receives in foreground via WebSocket → `flutter_local_notifications` shown + TTS announced → User says "Read All My Notifications" → TTS reads each message → marked as read.
|
||||
### Flow 3 - SOS Alert
|
||||
|
||||
### Flow 5 — SOS Alert
|
||||
User says "Send SOS" (or hardware shortcut) → GPS captured → backend saves SOS event → high-priority FCM sent to Guardian → Guardian map opens to User's location → Guardian taps Acknowledge → FCM sent back to User → TTS "Guardian is on the way."
|
||||
User triggers SOS -> backend creates SOS event -> Guardian receives/loads SOS event -> Guardian taps acknowledge or resolve -> SOS status no longer piles up as unhandled.
|
||||
|
||||
### Flow 6 — Geofence
|
||||
Guardian sets center + radius on map → User's location updates checked against Haversine distance → on exit: FCM to Guardian + `GEOFENCE_EXIT` activity log.
|
||||
### Flow 4 - Guardian Notification
|
||||
|
||||
---
|
||||
Guardian opens send notification -> chooses text or voice note mode -> sends payload -> backend stores notification -> User notification screen can display/read/play supported message data.
|
||||
|
||||
## Quick Start (WIP)
|
||||
### Flow 5 - Location And Map
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/YourGroup/walkguide-final-exam.git
|
||||
User sends location updates -> backend stores location history -> Guardian map/dashboard reads the latest location and history -> WebSocket support exists for real-time updates.
|
||||
|
||||
# 2. Run Backend (Spring Boot)
|
||||
cd walkguide-backend
|
||||
./mvnw spring-boot:run
|
||||
# Flyway auto-migrates V1–V16 on first start
|
||||
# Swagger UI: http://202.46.28.160:8080/swagger-ui.html
|
||||
# Health check: GET http://202.46.28.160:8080/api/v1/auth/ping
|
||||
### Flow 6 - Call
|
||||
|
||||
# 3. Run Mobile (Flutter) - Connect to local or university backend
|
||||
cd ../walkguide-mobile
|
||||
Caller requests token/channel -> backend returns call token payload -> caller notifies target -> target receives incoming call flow -> call can be ended through shared endpoint.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Backend
|
||||
|
||||
```powershell
|
||||
cd "D:\CodeSpace\Final Project Gabungan - Broken Test\walkguide-backend\demo"
|
||||
.\mvnw.cmd spring-boot:run -Dspring-boot.run.profiles=dev
|
||||
```
|
||||
|
||||
Health check:
|
||||
|
||||
```text
|
||||
http://localhost:8080/api/v1/auth/ping
|
||||
```
|
||||
|
||||
Swagger UI:
|
||||
|
||||
```text
|
||||
http://localhost:8080/swagger-ui.html
|
||||
```
|
||||
|
||||
Local/dev configuration reads database and secret values from environment variables or from the gitignored `secrets.properties` file:
|
||||
|
||||
```powershell
|
||||
$env:DB_URL="jdbc:postgresql://<host>:<port>/<database>"
|
||||
$env:DB_USERNAME="<database_username>"
|
||||
$env:DB_PASSWORD="<database_password>"
|
||||
$env:JWT_SECRET="your-base64-secret"
|
||||
```
|
||||
|
||||
### Flutter
|
||||
|
||||
```powershell
|
||||
cd "D:\CodeSpace\Final Project Gabungan - Broken Test\walkguide-mobile\walkguide_app"
|
||||
flutter pub get
|
||||
flutter run
|
||||
# On first launch: enter server URL in ServerConnectScreen
|
||||
# Example: http://202.46.28.160:8080
|
||||
```
|
||||
|
||||
---
|
||||
For Chrome/web debug:
|
||||
|
||||
```powershell
|
||||
flutter run -d chrome -t lib/main.dart
|
||||
```
|
||||
|
||||
For Android APK:
|
||||
|
||||
```powershell
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
On a physical phone, the server URL must be reachable by the phone. Use the university server URL or your laptop LAN IP, not `localhost`.
|
||||
|
||||
## Results
|
||||
|
||||
> ⏳ **Work In Progress:** Results are populated as benchmarking phases are completed.
|
||||
Final benchmark values should be filled from real test runs before submission.
|
||||
|
||||
| Metric | Baseline | Final Optimized | Status |
|
||||
|---|---|---|---|
|
||||
| Cold Start Time | *Pending* | *Pending* | ⏳ |
|
||||
| Memory Leak (10 Navs) | *Pending* | *Pending* | ⏳ |
|
||||
| API Error Rate | *Pending* | *Pending* | ⏳ |
|
||||
| YOLO Inference Latency (ms) | *Pending* | *Pending* | ⏳ |
|
||||
| API p95 Latency | *Pending* | *Pending* | ⏳ |
|
||||
| JaCoCo Coverage | *Pending* | *Pending* | ⏳ |
|
||||
|
||||
---
|
||||
| Metric | Evidence Location / Tool | Current README Status |
|
||||
|---|---|---|
|
||||
| Backend tests | Maven/JUnit output | To be generated |
|
||||
| Backend coverage | JaCoCo report | To be generated |
|
||||
| Backend load | k6 results | Assets present, final run needed |
|
||||
| Flutter tests | `flutter test` / integration tests | To be generated |
|
||||
| Flutter performance | Physical Android profile evidence | To be generated |
|
||||
| APK size | `flutter build apk --analyze-size` | To be generated |
|
||||
|
||||
## Weekly Progress
|
||||
|
||||
| Week | Target | Status |
|
||||
| Week | Target | Current Status |
|
||||
|---|---|---|
|
||||
| 1 | Topic proposal, Use Case definitions, Repo setup | ✅ Done |
|
||||
| 2–3 | OOAD diagrams, OpenAPI YAML drafted | 🔄 In Progress |
|
||||
| 4 | Spring Boot: Auth, Pairing, Entity, Migration V4–V16 | 🔄 In Progress |
|
||||
| 5 | Spring Boot: Location, SOS, Notification, WS, FCM, Agora | ⏳ Pending |
|
||||
| 6 | Spring Boot: Unit + Integration tests, JaCoCo ≥ 70% | ⏳ Pending |
|
||||
| 7 | Flutter: ServerConnect, Auth, WalkGuide + YOLO pipeline | ⏳ Pending |
|
||||
| 8 | Flutter: Guardian dashboard, Call, SOS, Notifications | ⏳ Pending |
|
||||
| 9 | Feature freeze, integration testing, benchmark on device | ⏳ Pending |
|
||||
| 10 | Final benchmarks, Report writing, Demo Video | ⏳ Pending |
|
||||
|
||||
---
|
||||
| 1 | Topic proposal, use case definitions, repo setup | Done |
|
||||
| 2-3 | OOAD diagrams and traceability | Done, docs present in `ooad-docs/` |
|
||||
| 4 | Spring Boot auth, pairing, entities, migrations | Implemented |
|
||||
| 5 | Location, SOS, notification, WebSocket, FCM, call support | Implemented with demo/service integrations |
|
||||
| 6 | Backend unit/integration testing and coverage setup | Implemented, final run evidence needed |
|
||||
| 7 | Flutter server connect, auth, WalkGuide/YOLO support | Implemented |
|
||||
| 8 | Guardian dashboard, SOS, notifications, voice notes, settings | Implemented |
|
||||
| 9 | Integration testing and benchmark evidence | Needs final evidence run |
|
||||
| 10 | Report, demo video, final submission polish | In progress |
|
||||
|
||||
## Core Objectives
|
||||
|
||||
**O₁ (Performance):** The dual-platform architecture must maintain a 60fps UI frame rate while handling concurrent backend requests without exceeding 500ms latency. YOLO inference runs on a separate isolate to avoid blocking the UI thread.
|
||||
O1 Performance: Keep obstacle detection local/on-device where possible and use backend for persistence, pairing, configuration, and real-time coordination.
|
||||
|
||||
**O₂ (Accessibility):** The User mode must require zero visual interaction post-login, relying entirely on haptics, voice commands, and physical button mapping. All screen transitions are announced via TTS.
|
||||
O2 Accessibility: Support voice/TTS, haptic feedback, large touch targets, and hardware shortcut flows for visually impaired users.
|
||||
|
||||
**O₃ (Traceability):** Every major feature implementation must be directly traceable back to the pre-development OOAD artifacts and GoF design patterns.
|
||||
O3 Traceability: Keep implementation aligned with `FULL_FLOW_ARCHITECTURE.md`, `FINAL_EXAM_GUIDE.md`, and the PUML diagrams in `ooad-docs/`.
|
||||
|
||||
**O₄ (Configurability):** All AI sensitivity settings, voice command phrases, and hardware shortcuts are remotely configurable by the Guardian without requiring an app update.
|
||||
|
||||
---
|
||||
O4 Configurability: Let Guardian configure AI sensitivity, voice commands, hardware shortcuts, geofence, and User settings through dashboard flows.
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the [MIT License](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
*Final Exam: Integrated Mobile Application Project* <br>
|
||||
*Flutter × Spring Boot × Object-Oriented Analysis and Design*
|
||||
Final Exam: Integrated Mobile Application Project
|
||||
Flutter x Spring Boot x Object-Oriented Analysis and Design
|
||||
|
||||
3023
hs_err_pid17212.log
Normal file
3023
hs_err_pid17212.log
Normal file
File diff suppressed because it is too large
Load Diff
@ -46,16 +46,14 @@ package "③ Facade Pattern [Structural]" #FFF8E1 {
|
||||
class "VoiceCommandHandler\n<<Facade>>" as VoiceCommandHandler {
|
||||
- _ttsService : TtsService
|
||||
- _sttService : SttService
|
||||
- _router : GoRouter
|
||||
- _walkGuideBloc : WalkGuideBloc
|
||||
- _sosBloc : SosBloc
|
||||
- _notifBloc : NotificationBloc
|
||||
- _router : CommandRouter
|
||||
- _actions : Map<VoiceCommandKey, CommandAction>
|
||||
+ processText(String command) : void
|
||||
- _matchCommand(String) : VoiceCommandKey?
|
||||
- _executeCommand(VoiceCommandKey) : void
|
||||
}
|
||||
|
||||
class "WalkGuideBloc\n<<Client>>" as WalkGuideBlocFacade {
|
||||
class "WalkGuideCubit\n<<Client>>" as WalkGuideCubitFacade {
|
||||
+ onVoiceCommand(String text)
|
||||
}
|
||||
|
||||
@ -69,8 +67,8 @@ package "③ Facade Pattern [Structural]" #FFF8E1 {
|
||||
|
||||
class "SttService " as SttServiceFacade <<Subsystem>>
|
||||
class "TtsService " as TtsServiceFacade <<Subsystem>>
|
||||
class "GoRouter\n<<Router>>" as GoRouterFacade <<Subsystem>>
|
||||
class "SosBloc " as SosBlocFacade <<Subsystem>>
|
||||
class "CommandRouter\n<<Router Adapter>>" as GoRouterFacade <<Subsystem>>
|
||||
class "CommandAction\n<<Cubit Callback>>" as CommandActionFacade <<Subsystem>>
|
||||
|
||||
class "LocationService\n<<Service>>" as LocationService <<Subsystem>>
|
||||
class "ActivityLogService\n<<Service>>" as ActivityService <<Subsystem>>
|
||||
@ -82,11 +80,11 @@ package "③ Facade Pattern [Structural]" #FFF8E1 {
|
||||
' GET /api/v1/guardian/dashboard
|
||||
}
|
||||
|
||||
WalkGuideBlocFacade --> VoiceCommandHandler : processText()
|
||||
WalkGuideCubitFacade --> VoiceCommandHandler : processText()
|
||||
VoiceCommandHandler --> SttServiceFacade : delegates
|
||||
VoiceCommandHandler --> TtsServiceFacade : delegates
|
||||
VoiceCommandHandler --> GoRouterFacade : delegates
|
||||
VoiceCommandHandler --> SosBlocFacade : delegates
|
||||
VoiceCommandHandler --> CommandActionFacade : delegates
|
||||
|
||||
GuardianDashboardController --> GuardianDashboardService : getDashboard()
|
||||
GuardianDashboardService --> LocationService : aggregates
|
||||
|
||||
@ -57,8 +57,8 @@ package "④ Repository Pattern [Structural — Proxy]" #FFF3E0 {
|
||||
}
|
||||
|
||||
class "WalkGuideRepositoryImpl\n<<Proxy>>" as WalkGuideRepoImpl {
|
||||
- _remoteDataSource : WalkGuideRemoteDataSource
|
||||
- _localDataSource : WalkGuideLocalDataSource
|
||||
- _apiClient : ApiClient
|
||||
- _offlineQueue : OfflineQueueService
|
||||
- _connectivity : ConnectivityPlus
|
||||
+ startSession() : Either<Failure, void>
|
||||
+ logObstacle(req) : Either<Failure, void>
|
||||
@ -72,16 +72,16 @@ package "④ Repository Pattern [Structural — Proxy]" #FFF3E0 {
|
||||
+ syncPending() : Either<Failure, void>
|
||||
}
|
||||
|
||||
class "WalkGuideRemoteDataSource\n<<Remote>>" as RemoteDSWalk {
|
||||
class "ApiClient\n<<Remote>>" as RemoteDSWalk {
|
||||
+ startSession() : void
|
||||
+ logObstacle(req) : void
|
||||
' POST /api/v1/user/obstacle
|
||||
}
|
||||
|
||||
class "WalkGuideLocalDataSource\n<<SQLite/Drift>>" as LocalDSWalk {
|
||||
class "OfflineQueueService + LocalDatabase\n<<SQLite Cache>>" as LocalDSWalk {
|
||||
+ cacheObstacle(ObstacleLog) : void
|
||||
+ getPendingLogs() : List<ObstacleLog>
|
||||
' Drift ORM — offline first
|
||||
' SQLite-backed offline first
|
||||
}
|
||||
|
||||
WalkGuideRepo <|.. WalkGuideRepoImpl : implements
|
||||
|
||||
@ -43,30 +43,27 @@ skinparam note {
|
||||
|
||||
package "⑤ Observer Pattern [Behavioral]" #F3E5F5 {
|
||||
|
||||
abstract class "Bloc<Event, State>\n<<Subject>>" as BlocSubject {
|
||||
abstract class "Cubit<State>\n<<Subject>>" as BlocSubject {
|
||||
# stateController : StreamController<State>
|
||||
+ {abstract} on<E>(EventHandler)
|
||||
+ add(Event event)
|
||||
+ emit(State state)
|
||||
+ stream : Stream<State>
|
||||
}
|
||||
|
||||
class "WalkGuideBloc\n<<ConcreteSubject>>" as WalkGuideBlocObs {
|
||||
+ on<StartWalkGuide>(_onStart)
|
||||
+ on<StopWalkGuide>(_onStop)
|
||||
+ on<CameraFrameReceived>(_onFrame)
|
||||
+ on<ObstacleDetected>(_onObstacle)
|
||||
class "WalkGuideCubit\n<<ConcreteSubject>>" as WalkGuideCubitObs {
|
||||
+ start()
|
||||
+ stop()
|
||||
+ logObstacle()
|
||||
- _yoloDetector : YoloDetector
|
||||
- _ttsService : TtsService
|
||||
- _hapticService : HapticService
|
||||
}
|
||||
|
||||
class "BlocBuilder<WalkGuideBloc, WalkGuideState>\n<<Observer / Widget>>" as BlocBuilderWidget {
|
||||
class "BlocBuilder<WalkGuideCubit, WalkGuideState>\n<<Observer / Widget>>" as BlocBuilderWidget {
|
||||
+ builder(ctx, state) : Widget
|
||||
' Rebuilds UI on every state emission
|
||||
}
|
||||
|
||||
class "BlocListener<WalkGuideBloc, WalkGuideState>\n<<Observer / SideEffect>>" as BlocListenerWidget {
|
||||
class "BlocListener<WalkGuideCubit, WalkGuideState>\n<<Observer / SideEffect>>" as BlocListenerWidget {
|
||||
+ listener(ctx, state) : void
|
||||
' Side effects: TTS, haptic, navigation
|
||||
}
|
||||
@ -84,9 +81,9 @@ package "⑤ Observer Pattern [Behavioral]" #F3E5F5 {
|
||||
' Updates flutter_map markers in real-time
|
||||
}
|
||||
|
||||
BlocSubject <|-- WalkGuideBlocObs : extends
|
||||
WalkGuideBlocObs --> BlocBuilderWidget : emits state\n(rebuilds UI)
|
||||
WalkGuideBlocObs --> BlocListenerWidget : emits state\n(side effects)
|
||||
BlocSubject <|-- WalkGuideCubitObs : extends
|
||||
WalkGuideCubitObs --> BlocBuilderWidget : emits state\n(rebuilds UI)
|
||||
WalkGuideCubitObs --> BlocListenerWidget : emits state\n(side effects)
|
||||
WebSocketObs --> GuardianMapObs : notifies\nlive location
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -104,6 +104,13 @@
|
||||
<!-- yang di-copy dari Agora open-source token builder. Tidak perlu library eksternal. -->
|
||||
<!-- Referensi: https://github.com/AgoraIO/Tools/tree/master/DynamicKey/AgoraDynamicKey/java -->
|
||||
|
||||
<!-- FIREBASE ADMIN SDK: FCM push + Firestore notification audit trail -->
|
||||
<dependency>
|
||||
<groupId>com.google.firebase</groupId>
|
||||
<artifactId>firebase-admin</artifactId>
|
||||
<version>9.3.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- TESTING -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
10
walkguide-backend/demo/secrets.properties.example
Normal file
10
walkguide-backend/demo/secrets.properties.example
Normal 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
|
||||
@ -0,0 +1,52 @@
|
||||
package com.walkguide.config;
|
||||
|
||||
import com.google.auth.oauth2.GoogleCredentials;
|
||||
import com.google.firebase.FirebaseApp;
|
||||
import com.google.firebase.FirebaseOptions;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FirebaseConfig {
|
||||
|
||||
private final ResourceLoader resourceLoader;
|
||||
|
||||
@Value("${firebase.credentials-path:classpath:firebase/google-services-admin.json}")
|
||||
private String credentialsPath;
|
||||
|
||||
@PostConstruct
|
||||
void initializeFirebase() {
|
||||
if (!FirebaseApp.getApps().isEmpty()) {
|
||||
log.info("[FIREBASE] FirebaseApp already initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Resource resource = resourceLoader.getResource(credentialsPath);
|
||||
if (!resource.exists() || !resource.isReadable()) {
|
||||
log.warn("[FIREBASE] Credential not found/readable at {}. FCM runs in log-only fallback.", credentialsPath);
|
||||
return;
|
||||
}
|
||||
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
FirebaseOptions options = FirebaseOptions.builder()
|
||||
.setCredentials(GoogleCredentials.fromStream(in))
|
||||
.build();
|
||||
FirebaseApp.initializeApp(options);
|
||||
}
|
||||
|
||||
log.info("[FIREBASE] Firebase Admin initialized from {}", credentialsPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("[FIREBASE] Failed to initialize Firebase Admin. FCM fallback active: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -36,11 +36,14 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
// Endpoint WebSocket utama
|
||||
// Endpoint WebSocket utama untuk Flutter/stomp_dart_client.
|
||||
// Flutter connect ke: ws://host:port/ws (tanpa SockJS)
|
||||
// Browser/Postman bisa pakai SockJS fallback: http://host:port/ws
|
||||
registry.addEndpoint("/ws")
|
||||
.setAllowedOriginPatterns("*") // Allow semua origin untuk testing HP di LAN
|
||||
.withSockJS(); // SockJS fallback untuk browser compatibility
|
||||
.setAllowedOriginPatterns("*"); // Allow semua origin untuk testing HP di LAN
|
||||
|
||||
// Endpoint fallback SockJS untuk browser tooling bila dibutuhkan.
|
||||
registry.addEndpoint("/ws-sockjs")
|
||||
.setAllowedOriginPatterns("*")
|
||||
.withSockJS();
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,9 +14,12 @@ import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
@ -36,35 +39,80 @@ public class CallController {
|
||||
@Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
|
||||
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
|
||||
@Valid @RequestBody CallTokenRequest req) {
|
||||
|
||||
Long callerId = SecurityHelper.getCurrentUserId();
|
||||
AgoraTokenResponse response = agoraTokenService.generateToken(callerId, req.getReceiverId());
|
||||
|
||||
log.info("[CALL] Token generated | caller={} receiver={} channel={}",
|
||||
callerId, req.getReceiverId(), response.getChannelName());
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
|
||||
}
|
||||
|
||||
@PostMapping("/notify")
|
||||
@Operation(summary = "Notify receiver of incoming call")
|
||||
public ResponseEntity<ApiResponse<Void>> notifyCall(
|
||||
@Valid @RequestBody CallNotifyRequest req) {
|
||||
|
||||
public ResponseEntity<ApiResponse<Void>> notifyCall(@Valid @RequestBody CallNotifyRequest req) {
|
||||
Long callerId = SecurityHelper.getCurrentUserId();
|
||||
String message = callNotificationService.notifyIncomingCall(callerId, req);
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, message));
|
||||
}
|
||||
|
||||
@PostMapping("/accept")
|
||||
@Operation(summary = "Receiver accepts incoming call")
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> acceptCall(@RequestBody Map<String, String> body) {
|
||||
Long receiverId = SecurityHelper.getCurrentUserId();
|
||||
Long callerId = Long.parseLong(body.get("callerId"));
|
||||
String channelName = body.get("channelName");
|
||||
return ResponseEntity.ok(ApiResponse.ok(
|
||||
callNotificationService.acceptCall(receiverId, callerId, channelName),
|
||||
"Call accepted"
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/pending")
|
||||
@Operation(summary = "Get pending incoming call for logged-in receiver")
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> pendingCall() {
|
||||
Long receiverId = SecurityHelper.getCurrentUserId();
|
||||
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getPendingCall(receiverId), "Pending call"));
|
||||
}
|
||||
|
||||
@DeleteMapping("/pending")
|
||||
@Operation(summary = "Clear pending incoming call for logged-in receiver")
|
||||
public ResponseEntity<ApiResponse<Void>> clearPendingCall() {
|
||||
Long receiverId = SecurityHelper.getCurrentUserId();
|
||||
callNotificationService.clearPendingCall(receiverId);
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, "Pending call cleared"));
|
||||
}
|
||||
|
||||
@GetMapping("/accepted")
|
||||
@Operation(summary = "Get accepted call for logged-in caller")
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> acceptedCall() {
|
||||
Long callerId = SecurityHelper.getCurrentUserId();
|
||||
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getAcceptedCall(callerId), "Accepted call"));
|
||||
}
|
||||
|
||||
@DeleteMapping("/accepted")
|
||||
@Operation(summary = "Clear accepted call for logged-in caller")
|
||||
public ResponseEntity<ApiResponse<Void>> clearAcceptedCall() {
|
||||
Long callerId = SecurityHelper.getCurrentUserId();
|
||||
callNotificationService.clearAcceptedCall(callerId);
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, "Accepted call cleared"));
|
||||
}
|
||||
|
||||
@GetMapping("/state")
|
||||
@Operation(summary = "Get call state by Agora channel")
|
||||
public ResponseEntity<ApiResponse<Map<String, String>>> callState(@RequestParam String channelName) {
|
||||
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getCallState(channelName), "Call state"));
|
||||
}
|
||||
|
||||
@PostMapping("/end")
|
||||
@Operation(summary = "Notify end of call")
|
||||
public ResponseEntity<ApiResponse<Void>> endCall(
|
||||
@RequestBody Map<String, Long> body) {
|
||||
|
||||
public ResponseEntity<ApiResponse<Void>> endCall(@RequestBody Map<String, String> body) {
|
||||
Long callerId = SecurityHelper.getCurrentUserId();
|
||||
Long otherId = body.get("otherId");
|
||||
callNotificationService.notifyCallEnded(callerId, otherId);
|
||||
|
||||
Long otherId = Long.parseLong(body.get("otherId"));
|
||||
String channelName = body.get("channelName");
|
||||
if (channelName == null || channelName.isBlank()) {
|
||||
callNotificationService.notifyCallEnded(callerId, otherId);
|
||||
} else {
|
||||
callNotificationService.notifyCallEnded(callerId, otherId, channelName);
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,8 @@ public class CallNotifyRequest {
|
||||
/** Token Agora untuk receiver — dikirim lewat FCM payload */
|
||||
private String agoraToken;
|
||||
|
||||
private String agoraAppId;
|
||||
|
||||
/** UID Agora untuk receiver */
|
||||
private int receiverUid;
|
||||
}
|
||||
|
||||
@ -8,4 +8,5 @@ public class LocationUpdateRequest {
|
||||
private Double accuracy;
|
||||
private Double speed;
|
||||
private Double heading;
|
||||
private Integer batteryLevel;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.AllArgsConstructor;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@ -22,6 +23,8 @@ public class DashboardResponse {
|
||||
// Status
|
||||
private long unreadSosCount;
|
||||
private long unreadNotifCount;
|
||||
private long obstaclesToday;
|
||||
private Map<String, Object> userStatus;
|
||||
|
||||
// Recent activity (5 terbaru)
|
||||
private List<ActivityLogResponse> recentActivity;
|
||||
|
||||
@ -16,5 +16,6 @@ public class LocationResponse {
|
||||
private Double accuracy;
|
||||
private Double speed;
|
||||
private Double heading;
|
||||
private Integer batteryLevel;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@ -29,6 +29,9 @@ public class LocationHistory {
|
||||
private Double speed; // m/s
|
||||
private Double heading; // derajat 0-360
|
||||
|
||||
@Column(name = "battery_level")
|
||||
private Integer batteryLevel;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.walkguide.exception;
|
||||
|
||||
import com.walkguide.dto.ApiResponse;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
@ -29,10 +30,22 @@ public class GlobalExceptionHandler {
|
||||
.body(ApiResponse.error("VALIDATION_ERROR", msg));
|
||||
}
|
||||
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ApiResponse<Object>> handleDataIntegrity(DataIntegrityViolationException ex) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResponse.error("DATA_CONFLICT",
|
||||
"Data pairing lama masih bentrok. Refresh status atau unpair dulu, lalu coba lagi."));
|
||||
}
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<ApiResponse<Object>> handleRuntime(RuntimeException ex) {
|
||||
String message = ex.getMessage();
|
||||
if ("Email tidak terdaftar".equals(message) || "Password salah".equals(message)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(ApiResponse.error("AUTH_INVALID", message));
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error("INTERNAL_ERROR", ex.getMessage()));
|
||||
.body(ApiResponse.error("INTERNAL_ERROR", message));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
|
||||
@ -4,7 +4,11 @@ import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Repository
|
||||
public interface ObstacleLogRepository extends JpaRepository<ObstacleLog, Long> {
|
||||
Page<ObstacleLog> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
|
||||
long countByUserIdAndCreatedAtAfter(Long userId, LocalDateTime createdAt);
|
||||
}
|
||||
|
||||
@ -8,7 +8,10 @@ import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.Key;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
@ -82,7 +85,34 @@ public class JwtUtil {
|
||||
}
|
||||
|
||||
private Key getSignInKey() {
|
||||
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
|
||||
byte[] keyBytes = decodeSecret(secretKey);
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
|
||||
private byte[] decodeSecret(String configuredSecret) {
|
||||
String trimmed = configuredSecret == null ? "" : configuredSecret.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalStateException("JWT secret must not be empty");
|
||||
}
|
||||
|
||||
byte[] keyBytes;
|
||||
try {
|
||||
keyBytes = Decoders.BASE64.decode(trimmed);
|
||||
} catch (RuntimeException base64Error) {
|
||||
try {
|
||||
keyBytes = Decoders.BASE64URL.decode(trimmed);
|
||||
} catch (RuntimeException base64UrlError) {
|
||||
keyBytes = trimmed.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
if (keyBytes.length >= 32) {
|
||||
return keyBytes;
|
||||
}
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(keyBytes);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 is not available", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,11 +4,14 @@ import com.walkguide.dto.request.CallNotifyRequest;
|
||||
import com.walkguide.entity.User;
|
||||
import com.walkguide.exception.ResourceNotFoundException;
|
||||
import com.walkguide.repository.UserRepository;
|
||||
import com.walkguide.websocket.LocationBroadcaster;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ -17,29 +20,39 @@ public class CallNotificationService {
|
||||
|
||||
private final FcmService fcmService;
|
||||
private final UserRepository userRepository;
|
||||
private final LocationBroadcaster locationBroadcaster;
|
||||
private final Map<Long, Map<String, String>> pendingCalls = new ConcurrentHashMap<>();
|
||||
private final Map<Long, Map<String, String>> acceptedCalls = new ConcurrentHashMap<>();
|
||||
private final Map<String, Map<String, String>> callStates = new ConcurrentHashMap<>();
|
||||
|
||||
public String notifyIncomingCall(Long callerId, CallNotifyRequest req) {
|
||||
User caller = userRepository.findById(callerId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
|
||||
|
||||
User receiver = userRepository.findById(req.getReceiverId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
|
||||
|
||||
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
|
||||
Map<String, String> payload = new HashMap<>();
|
||||
payload.put("type", "INCOMING_CALL");
|
||||
payload.put("status", "RINGING");
|
||||
payload.put("callerId", String.valueOf(callerId));
|
||||
payload.put("receiverId", String.valueOf(receiver.getId()));
|
||||
payload.put("callerName", callerName);
|
||||
payload.put("channelName", req.getChannelName());
|
||||
payload.put("agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "");
|
||||
payload.put("agoraAppId", req.getAgoraAppId() != null ? req.getAgoraAppId() : "");
|
||||
payload.put("receiverUid", String.valueOf(req.getReceiverUid()));
|
||||
|
||||
pendingCalls.put(receiver.getId(), payload);
|
||||
acceptedCalls.remove(callerId);
|
||||
callStates.put(req.getChannelName(), payload);
|
||||
locationBroadcaster.broadcastCall(receiver.getId(), payload);
|
||||
|
||||
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
|
||||
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId());
|
||||
return "Panggilan dikirim (receiver mungkin tidak menerima push notification)";
|
||||
return "Panggilan dikirim via realtime fallback.";
|
||||
}
|
||||
|
||||
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
|
||||
Map<String, String> payload = Map.of(
|
||||
"type", "INCOMING_CALL",
|
||||
"callerId", String.valueOf(callerId),
|
||||
"callerName", callerName,
|
||||
"channelName", req.getChannelName(),
|
||||
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
|
||||
"receiverUid", String.valueOf(req.getReceiverUid())
|
||||
);
|
||||
|
||||
fcmService.sendHighPriority(
|
||||
receiver.getFcmToken(),
|
||||
"Panggilan Masuk",
|
||||
@ -52,22 +65,111 @@ public class CallNotificationService {
|
||||
return "Notifikasi panggilan berhasil dikirim";
|
||||
}
|
||||
|
||||
public Map<String, String> acceptCall(Long receiverId, Long callerId, String channelName) {
|
||||
User receiver = userRepository.findById(receiverId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
|
||||
userRepository.findById(callerId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
|
||||
|
||||
pendingCalls.remove(receiverId);
|
||||
String receiverName = receiver.getDisplayName() != null ? receiver.getDisplayName() : receiver.getEmail();
|
||||
Map<String, String> payload = new HashMap<>(getCallState(channelName));
|
||||
payload.put("type", "CALL_ACCEPTED");
|
||||
payload.put("status", "ACCEPTED");
|
||||
payload.put("callerId", String.valueOf(callerId));
|
||||
payload.put("receiverId", String.valueOf(receiverId));
|
||||
payload.put("receiverName", receiverName);
|
||||
payload.put("channelName", channelName != null ? channelName : "");
|
||||
payload.put("acceptedBy", String.valueOf(receiverId));
|
||||
payload.put("acceptedAt", String.valueOf(System.currentTimeMillis()));
|
||||
|
||||
acceptedCalls.put(callerId, payload);
|
||||
if (channelName != null && !channelName.isBlank()) {
|
||||
callStates.put(channelName, payload);
|
||||
}
|
||||
locationBroadcaster.broadcastCall(callerId, payload);
|
||||
locationBroadcaster.broadcastCall(receiverId, payload);
|
||||
log.info("[CALL] Call accepted | caller={} receiver={} channel={}", callerId, receiverId, channelName);
|
||||
return payload;
|
||||
}
|
||||
|
||||
public Map<String, String> getPendingCall(Long receiverId) {
|
||||
return pendingCalls.get(receiverId);
|
||||
}
|
||||
|
||||
public void clearPendingCall(Long receiverId) {
|
||||
pendingCalls.remove(receiverId);
|
||||
}
|
||||
|
||||
public Map<String, String> getAcceptedCall(Long callerId) {
|
||||
return acceptedCalls.get(callerId);
|
||||
}
|
||||
|
||||
public void clearAcceptedCall(Long callerId) {
|
||||
acceptedCalls.remove(callerId);
|
||||
}
|
||||
|
||||
public Map<String, String> getCallState(String channelName) {
|
||||
if (channelName == null || channelName.isBlank()) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
return callStates.getOrDefault(channelName, new HashMap<>());
|
||||
}
|
||||
|
||||
public void notifyCallEnded(Long callerId, Long otherId) {
|
||||
notifyCallEnded(callerId, otherId, null);
|
||||
}
|
||||
|
||||
public void notifyCallEnded(Long callerId, Long otherId, String channelName) {
|
||||
if (otherId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingCall(otherId);
|
||||
clearPendingCall(callerId);
|
||||
clearAcceptedCall(callerId);
|
||||
clearAcceptedCall(otherId);
|
||||
|
||||
String resolvedChannel = channelName;
|
||||
if (resolvedChannel == null || resolvedChannel.isBlank()) {
|
||||
resolvedChannel = findActiveChannel(callerId, otherId);
|
||||
}
|
||||
Map<String, String> payload = new HashMap<>(getCallState(resolvedChannel));
|
||||
payload.put("type", "CALL_ENDED");
|
||||
payload.put("status", "ENDED");
|
||||
payload.put("callerId", String.valueOf(callerId));
|
||||
payload.put("otherId", String.valueOf(otherId));
|
||||
payload.put("channelName", resolvedChannel != null ? resolvedChannel : "");
|
||||
payload.put("endedBy", String.valueOf(callerId));
|
||||
payload.put("endedAt", String.valueOf(System.currentTimeMillis()));
|
||||
if (resolvedChannel != null && !resolvedChannel.isBlank()) {
|
||||
callStates.put(resolvedChannel, payload);
|
||||
}
|
||||
|
||||
locationBroadcaster.broadcastCall(otherId, payload);
|
||||
locationBroadcaster.broadcastCall(callerId, payload);
|
||||
|
||||
userRepository.findById(otherId).ifPresent(other -> {
|
||||
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
fcmService.sendToToken(
|
||||
other.getFcmToken(),
|
||||
"Panggilan Berakhir",
|
||||
"Panggilan telah berakhir",
|
||||
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
|
||||
payload
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private String findActiveChannel(Long userA, Long userB) {
|
||||
String a = String.valueOf(userA);
|
||||
String b = String.valueOf(userB);
|
||||
return callStates.entrySet().stream()
|
||||
.filter(e -> a.equals(e.getValue().get("callerId")) && b.equals(e.getValue().get("receiverId"))
|
||||
|| b.equals(e.getValue().get("callerId")) && a.equals(e.getValue().get("receiverId")))
|
||||
.map(Map.Entry::getKey)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,50 +1,130 @@
|
||||
package com.walkguide.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import com.google.cloud.Timestamp;
|
||||
import com.google.cloud.firestore.Firestore;
|
||||
import com.google.firebase.FirebaseApp;
|
||||
import com.google.firebase.cloud.FirestoreClient;
|
||||
import com.google.firebase.messaging.AndroidConfig;
|
||||
import com.google.firebase.messaging.AndroidNotification;
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import com.google.firebase.messaging.Message;
|
||||
import com.google.firebase.messaging.Notification;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* FCM Service untuk push notification.
|
||||
* Saat ini dalam mode LOG-ONLY agar tidak butuh Firebase credentials dulu.
|
||||
* Untuk enable FCM nyata: uncomment bagian Firebase dan tambah dependency Firebase Admin SDK.
|
||||
* FCM Service untuk push notification dan audit notifikasi ke Firestore.
|
||||
* Jika Firebase credential belum tersedia, service tetap aman berjalan dalam mode log-only.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FcmService {
|
||||
|
||||
public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) {
|
||||
if (fcmToken == null || fcmToken.isBlank()) {
|
||||
log.warn("[FCM] Token kosong, skip notifikasi: {} - {}", title, body);
|
||||
return;
|
||||
}
|
||||
// LOG ONLY untuk sekarang
|
||||
log.info("[FCM] TO={} | TITLE={} | BODY={} | DATA={}", fcmToken, title, body, data);
|
||||
private static final Logger log = LoggerFactory.getLogger(FcmService.class);
|
||||
|
||||
// TODO: uncomment ini setelah tambah Firebase Admin SDK ke pom.xml
|
||||
// dan taruh google-services-admin.json di src/main/resources/firebase/
|
||||
//
|
||||
// try {
|
||||
// Message message = Message.builder()
|
||||
// .setToken(fcmToken)
|
||||
// .setNotification(Notification.builder().setTitle(title).setBody(body).build())
|
||||
// .putAllData(data != null ? data : Map.of())
|
||||
// .setAndroidConfig(AndroidConfig.builder()
|
||||
// .setPriority(AndroidConfig.Priority.HIGH)
|
||||
// .build())
|
||||
// .build();
|
||||
// String response = FirebaseMessaging.getInstance().send(message);
|
||||
// log.info("[FCM] Sent successfully: {}", response);
|
||||
// } catch (FirebaseMessagingException e) {
|
||||
// log.error("[FCM] Failed to send: {}", e.getMessage());
|
||||
// }
|
||||
public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) {
|
||||
sendInternal(fcmToken, title, body, data, false);
|
||||
}
|
||||
|
||||
public void sendHighPriority(String fcmToken, String title, String body, Map<String, String> data) {
|
||||
// SOS dan incoming call pakai ini - sama untuk sekarang
|
||||
sendToToken(fcmToken, title, body, data);
|
||||
sendInternal(fcmToken, title, body, data, true);
|
||||
}
|
||||
|
||||
@Value("${firebase.notifications-collection:notifications}")
|
||||
private String notificationsCollection;
|
||||
|
||||
private void sendInternal(String fcmToken, String title, String body, Map<String, String> data, boolean highPriority) {
|
||||
Map<String, String> safeData = data != null ? data : Map.of();
|
||||
String status = "SKIPPED";
|
||||
String messageId = null;
|
||||
|
||||
if (fcmToken == null || fcmToken.isBlank()) {
|
||||
log.warn("[FCM] Token kosong, skip notifikasi: {} - {}", title, body);
|
||||
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (FirebaseApp.getApps().isEmpty()) {
|
||||
status = "LOG_ONLY";
|
||||
log.info("[FCM] LOG_ONLY TO={} | TITLE={} | BODY={} | DATA={}",
|
||||
maskToken(fcmToken), title, body, safeData);
|
||||
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
AndroidConfig.Priority priority = highPriority
|
||||
? AndroidConfig.Priority.HIGH
|
||||
: AndroidConfig.Priority.NORMAL;
|
||||
|
||||
AndroidNotification androidNotification = AndroidNotification.builder()
|
||||
.setChannelId(highPriority ? "walkguide_urgent" : "walkguide_alerts")
|
||||
.setPriority(highPriority
|
||||
? AndroidNotification.Priority.MAX
|
||||
: AndroidNotification.Priority.DEFAULT)
|
||||
.build();
|
||||
|
||||
Message message = Message.builder()
|
||||
.setToken(fcmToken)
|
||||
.setNotification(Notification.builder()
|
||||
.setTitle(title != null ? title : "WalkGuide")
|
||||
.setBody(body != null ? body : "")
|
||||
.build())
|
||||
.putAllData(safeData)
|
||||
.setAndroidConfig(AndroidConfig.builder()
|
||||
.setPriority(priority)
|
||||
.setNotification(androidNotification)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
messageId = FirebaseMessaging.getInstance().send(message);
|
||||
status = "SENT";
|
||||
log.info("[FCM] Sent {} notification successfully: {}", highPriority ? "high-priority" : "normal", messageId);
|
||||
} catch (Exception e) {
|
||||
status = "FAILED";
|
||||
log.error("[FCM] Failed to send notification: {}", e.getMessage());
|
||||
} finally {
|
||||
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, messageId);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveNotificationAudit(String fcmToken, String title, String body, Map<String, String> data,
|
||||
boolean highPriority, String status, String messageId) {
|
||||
if (FirebaseApp.getApps().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Firestore firestore = FirestoreClient.getFirestore();
|
||||
Map<String, Object> doc = new HashMap<>();
|
||||
doc.put("title", title);
|
||||
doc.put("body", body);
|
||||
doc.put("type", data.getOrDefault("type", "GENERAL"));
|
||||
doc.put("data", data);
|
||||
doc.put("priority", highPriority ? "HIGH" : "NORMAL");
|
||||
doc.put("status", status);
|
||||
doc.put("messageId", messageId);
|
||||
doc.put("recipientTokenMasked", maskToken(fcmToken));
|
||||
doc.put("createdAt", Timestamp.ofTimeSecondsAndNanos(Instant.now().getEpochSecond(), 0));
|
||||
|
||||
firestore.collection(notificationsCollection).add(doc).get();
|
||||
log.debug("[FIRESTORE] Notification audit saved | type={} status={}", doc.get("type"), status);
|
||||
} catch (Exception e) {
|
||||
log.warn("[FIRESTORE] Notification audit skipped: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String maskToken(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
int visible = Math.min(6, token.length());
|
||||
return "***" + token.substring(token.length() - visible);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,11 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GuardianDashboardService {
|
||||
@ -17,6 +22,7 @@ public class GuardianDashboardService {
|
||||
private final ActivityLogService activityLogService;
|
||||
private final SosEventRepository sosEventRepository;
|
||||
private final GuardianNotificationRepository notifRepository;
|
||||
private final ObstacleLogRepository obstacleLogRepository;
|
||||
|
||||
public DashboardResponse getDashboard(Long guardianId) {
|
||||
var pairing = pairingRelationRepository
|
||||
@ -40,6 +46,21 @@ public class GuardianDashboardService {
|
||||
long unreadSos = sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, 100))
|
||||
.stream().filter(s -> s.getStatus().name().equals("TRIGGERED")).count();
|
||||
long unreadNotif = notifRepository.countByUserIdAndIsReadFalse(userId);
|
||||
long obstaclesToday = obstacleLogRepository.countByUserIdAndCreatedAtAfter(
|
||||
userId,
|
||||
LocalDateTime.of(LocalDateTime.now().toLocalDate(), LocalTime.MIN)
|
||||
);
|
||||
|
||||
Map<String, Object> userStatus = new HashMap<>();
|
||||
userStatus.put("displayName", user.getDisplayName());
|
||||
userStatus.put("email", user.getEmail());
|
||||
userStatus.put("online", lastLocation != null
|
||||
&& lastLocation.getCreatedAt() != null
|
||||
&& lastLocation.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(2)));
|
||||
userStatus.put("lastSeenAt", lastLocation != null ? lastLocation.getCreatedAt() : null);
|
||||
userStatus.put("battery", lastLocation != null ? lastLocation.getBatteryLevel() : null);
|
||||
userStatus.put("lastSpeed", lastLocation != null ? lastLocation.getSpeed() : null);
|
||||
userStatus.put("obstaclesToday", obstaclesToday);
|
||||
|
||||
return DashboardResponse.builder()
|
||||
.pairedUserId(userId)
|
||||
@ -49,6 +70,8 @@ public class GuardianDashboardService {
|
||||
.lastLocation(lastLocation)
|
||||
.unreadSosCount(unreadSos)
|
||||
.unreadNotifCount(unreadNotif)
|
||||
.obstaclesToday(obstaclesToday)
|
||||
.userStatus(userStatus)
|
||||
.recentActivity(recentActivity)
|
||||
.build();
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@ public class LocationService {
|
||||
.accuracy(req.getAccuracy())
|
||||
.speed(req.getSpeed())
|
||||
.heading(req.getHeading())
|
||||
.batteryLevel(req.getBatteryLevel())
|
||||
.build();
|
||||
loc = locationHistoryRepository.save(loc);
|
||||
|
||||
@ -136,6 +137,7 @@ public class LocationService {
|
||||
return LocationResponse.builder()
|
||||
.id(l.getId()).lat(l.getLat()).lng(l.getLng())
|
||||
.accuracy(l.getAccuracy()).speed(l.getSpeed()).heading(l.getHeading())
|
||||
.batteryLevel(l.getBatteryLevel())
|
||||
.createdAt(l.getCreatedAt()).build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ import com.walkguide.enums.*;
|
||||
import com.walkguide.exception.PairingException;
|
||||
import com.walkguide.exception.ResourceNotFoundException;
|
||||
import com.walkguide.repository.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@ -18,7 +17,6 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PairingService {
|
||||
|
||||
private final PairingRelationRepository pairingRelationRepository;
|
||||
@ -34,6 +32,22 @@ public class PairingService {
|
||||
private static final int PAIRING_CODE_TTL_MINUTES = 15;
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
public PairingService(PairingRelationRepository pairingRelationRepository,
|
||||
UserRepository userRepository,
|
||||
VoiceCommandConfigRepository voiceCommandConfigRepository,
|
||||
HardwareShortcutRepository hardwareShortcutRepository,
|
||||
AiConfigRepository aiConfigRepository,
|
||||
ActivityLogService activityLogService,
|
||||
FcmService fcmService) {
|
||||
this.pairingRelationRepository = pairingRelationRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.voiceCommandConfigRepository = voiceCommandConfigRepository;
|
||||
this.hardwareShortcutRepository = hardwareShortcutRepository;
|
||||
this.aiConfigRepository = aiConfigRepository;
|
||||
this.activityLogService = activityLogService;
|
||||
this.fcmService = fcmService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PairingCodeResponse getOrCreatePairingCode(Long userId) {
|
||||
User user = userRepository.findById(userId)
|
||||
@ -69,7 +83,6 @@ public class PairingService {
|
||||
|
||||
@Transactional
|
||||
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
|
||||
// Guardian tidak boleh punya pairing ACTIVE atau PENDING
|
||||
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
|
||||
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
|
||||
}
|
||||
@ -88,6 +101,52 @@ public class PairingService {
|
||||
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
|
||||
}
|
||||
|
||||
var existingGuardianPairing = pairingRelationRepository.findByGuardian_Id(guardianId);
|
||||
if (existingGuardianPairing.isPresent()) {
|
||||
PairingRelation existing = existingGuardianPairing.get();
|
||||
if (existing.getStatus() == PairingStatus.ACTIVE) {
|
||||
if (existing.getUser().getId().equals(user.getId())) {
|
||||
return buildStatus(existing, guardian, existing.getUser(), "GUARDIAN");
|
||||
}
|
||||
throw new PairingException(
|
||||
"Guardian sudah pairing aktif dengan User lain. Unpair dulu sebelum invite User baru.");
|
||||
}
|
||||
if (existing.getStatus() == PairingStatus.PENDING) {
|
||||
if (existing.getUser().getId().equals(user.getId())) {
|
||||
sendPairingInviteNotification(existing, guardian, user);
|
||||
return buildStatus(existing, guardian, user, "GUARDIAN");
|
||||
}
|
||||
throw new PairingException(
|
||||
"Guardian masih punya undangan pairing yang menunggu respons User.");
|
||||
}
|
||||
}
|
||||
|
||||
var existingUserPairing = pairingRelationRepository.findByUser_Id(user.getId());
|
||||
if (existingUserPairing.isPresent()) {
|
||||
PairingRelation existing = existingUserPairing.get();
|
||||
if (existing.getStatus() == PairingStatus.ACTIVE) {
|
||||
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
|
||||
}
|
||||
if (existing.getStatus() == PairingStatus.PENDING) {
|
||||
if (existing.getGuardian().getId().equals(guardianId)) {
|
||||
sendPairingInviteNotification(existing, guardian, user);
|
||||
return buildStatus(existing, guardian, user, "GUARDIAN");
|
||||
}
|
||||
throw new PairingException("User ini masih punya undangan pairing dari Guardian lain.");
|
||||
}
|
||||
}
|
||||
|
||||
if (existingGuardianPairing.isPresent()) {
|
||||
pairingRelationRepository.delete(existingGuardianPairing.get());
|
||||
pairingRelationRepository.flush();
|
||||
}
|
||||
if (existingUserPairing.isPresent()
|
||||
&& (existingGuardianPairing.isEmpty()
|
||||
|| !existingUserPairing.get().getId().equals(existingGuardianPairing.get().getId()))) {
|
||||
pairingRelationRepository.delete(existingUserPairing.get());
|
||||
pairingRelationRepository.flush();
|
||||
}
|
||||
|
||||
PairingRelation pairing = PairingRelation.builder()
|
||||
.guardian(guardian)
|
||||
.user(user)
|
||||
@ -99,11 +158,7 @@ public class PairingService {
|
||||
user.setPairingCodeExpiresAt(null);
|
||||
userRepository.save(user);
|
||||
|
||||
// Kirim FCM ke user
|
||||
fcmService.sendToToken(user.getFcmToken(),
|
||||
"Pairing Request",
|
||||
"Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung",
|
||||
Map.of("type", "PAIRING_INVITE", "guardianName", guardian.getDisplayName()));
|
||||
sendPairingInviteNotification(pairing, guardian, user);
|
||||
|
||||
activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT,
|
||||
"Guardian mengirim invite ke " + user.getDisplayName(), null);
|
||||
@ -195,6 +250,13 @@ public class PairingService {
|
||||
// ========== PRIVATE ==========
|
||||
|
||||
private void seedDefaults(Long guardianId, Long userId) {
|
||||
voiceCommandConfigRepository.deleteByUserId(userId);
|
||||
hardwareShortcutRepository.deleteByUserId(userId);
|
||||
aiConfigRepository.findByUserId(userId).ifPresent(aiConfigRepository::delete);
|
||||
voiceCommandConfigRepository.flush();
|
||||
hardwareShortcutRepository.flush();
|
||||
aiConfigRepository.flush();
|
||||
|
||||
// Voice commands default
|
||||
List<VoiceCommandConfig> defaults = List.of(
|
||||
vc(guardianId, userId, VoiceCommandKey.OPEN_WALKGUIDE, "Open Walkguide"),
|
||||
@ -261,6 +323,15 @@ public class PairingService {
|
||||
return user;
|
||||
}
|
||||
|
||||
private void sendPairingInviteNotification(PairingRelation pairing, User guardian, User user) {
|
||||
fcmService.sendToToken(user.getFcmToken(),
|
||||
"Pairing Request",
|
||||
"Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung",
|
||||
Map.of(
|
||||
"type", "PAIRING_INVITE",
|
||||
"pairingId", pairing.getId().toString(),
|
||||
"guardianName", guardian.getDisplayName()));
|
||||
}
|
||||
private void assignNewPairingCode(User user, LocalDateTime now) {
|
||||
String candidate;
|
||||
do {
|
||||
@ -307,3 +378,4 @@ public class PairingService {
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import com.walkguide.entity.User;
|
||||
import com.walkguide.enums.ActivityLogType;
|
||||
import com.walkguide.enums.PairingStatus;
|
||||
import com.walkguide.enums.SosStatus;
|
||||
import com.walkguide.exception.PairingException;
|
||||
import com.walkguide.exception.ResourceNotFoundException;
|
||||
import com.walkguide.repository.*;
|
||||
import com.walkguide.websocket.LocationBroadcaster;
|
||||
@ -36,6 +37,14 @@ public class SosService {
|
||||
|
||||
@Transactional
|
||||
public SosEventResponse triggerSos(Long userId, SosRequest req) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
|
||||
|
||||
var activePairing = pairingRelationRepository
|
||||
.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
|
||||
.orElseThrow(() -> new PairingException(
|
||||
"SOS hanya bisa dikirim setelah User terhubung dengan Guardian aktif."));
|
||||
|
||||
SosEvent sos = SosEvent.builder()
|
||||
.userId(userId)
|
||||
.triggerType(req.getTriggerType() != null ? req.getTriggerType() : "MANUAL")
|
||||
@ -46,18 +55,13 @@ public class SosService {
|
||||
sos = sosEventRepository.save(sos);
|
||||
final SosEvent savedSos = sos;
|
||||
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
|
||||
|
||||
activityLogService.createLog(user, ActivityLogType.SOS_TRIGGERED,
|
||||
"SOS dikirim via " + sos.getTriggerType(), null);
|
||||
|
||||
SosEventResponse sosResponse = toResponse(savedSos);
|
||||
|
||||
// Kirim ke Guardian via FCM (background) + WebSocket (foreground)
|
||||
pairingRelationRepository.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
|
||||
.ifPresent(pairing -> {
|
||||
User guardian = pairing.getGuardian();
|
||||
User guardian = activePairing.getGuardian();
|
||||
String guardianFcm = guardian.getFcmToken();
|
||||
String locStr = req.getLat() != null
|
||||
? String.format("Lat:%.4f,Lng:%.4f", req.getLat(), req.getLng())
|
||||
@ -78,7 +82,6 @@ public class SosService {
|
||||
|
||||
log.info("[SOS] Alert sent to Guardian={} for User={} | trigger={}",
|
||||
guardian.getId(), userId, savedSos.getTriggerType());
|
||||
});
|
||||
|
||||
return sosResponse;
|
||||
}
|
||||
|
||||
@ -3,68 +3,49 @@ package com.walkguide.websocket;
|
||||
import com.walkguide.dto.response.LocationResponse;
|
||||
import com.walkguide.dto.response.NotificationResponse;
|
||||
import com.walkguide.dto.response.SosEventResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Service untuk broadcast pesan real-time via WebSocket (STOMP).
|
||||
*
|
||||
* Dipakai oleh:
|
||||
* - LocationService → broadcast GPS ke Guardian
|
||||
* - SosService → broadcast SOS ke Guardian
|
||||
* - NotificationService→ broadcast notif ke User
|
||||
*
|
||||
* PATTERN: Observer — Guardian/User subscribe ke topic,
|
||||
* LocationBroadcaster push data saat ada update.
|
||||
*/
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class LocationBroadcaster {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LocationBroadcaster.class);
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
/**
|
||||
* Broadcast lokasi GPS user ke Guardian yang subscribe.
|
||||
* Guardian Flutter subscribe ke: /topic/location/{userId}
|
||||
*
|
||||
* @param userId ID dari ROLE_USER (bukan guardian)
|
||||
* @param location Response lokasi terbaru
|
||||
*/
|
||||
public LocationBroadcaster(SimpMessagingTemplate messagingTemplate) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
}
|
||||
|
||||
public void broadcastLocation(Long userId, LocationResponse location) {
|
||||
String destination = "/topic/location/" + userId;
|
||||
messagingTemplate.convertAndSend(destination, location);
|
||||
log.debug("[WS] Location broadcast → {} | lat={} lng={}",
|
||||
log.debug("[WS] Location broadcast -> {} | lat={} lng={}",
|
||||
destination, location.getLat(), location.getLng());
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast SOS event ke Guardian secara real-time.
|
||||
* Guardian Flutter subscribe ke: /queue/sos/{guardianId}
|
||||
*
|
||||
* @param guardianId ID dari ROLE_GUARDIAN
|
||||
* @param sos SOS event yang baru di-trigger
|
||||
*/
|
||||
public void broadcastSos(Long guardianId, SosEventResponse sos) {
|
||||
String destination = "/queue/sos/" + guardianId;
|
||||
messagingTemplate.convertAndSend(destination, sos);
|
||||
log.info("[WS] SOS broadcast → {} | userId={} status={}",
|
||||
log.info("[WS] SOS broadcast -> {} | userId={} status={}",
|
||||
destination, sos.getUserId(), sos.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast notifikasi dari Guardian ke User secara real-time.
|
||||
* User Flutter subscribe ke: /queue/notif/{userId}
|
||||
*
|
||||
* @param userId ID dari ROLE_USER yang menerima notif
|
||||
* @param notification Notifikasi yang baru dikirim Guardian
|
||||
*/
|
||||
public void broadcastNotification(Long userId, NotificationResponse notification) {
|
||||
String destination = "/queue/notif/" + userId;
|
||||
messagingTemplate.convertAndSend(destination, notification);
|
||||
log.debug("[WS] Notification broadcast → {} | type={}",
|
||||
log.debug("[WS] Notification broadcast -> {} | type={}",
|
||||
destination, notification.getNotifType());
|
||||
}
|
||||
|
||||
public void broadcastCall(Long receiverId, Map<String, String> payload) {
|
||||
String destination = "/queue/call/" + receiverId;
|
||||
messagingTemplate.convertAndSend(destination, payload);
|
||||
log.info("[WS] Call broadcast -> {} | type={} status={} channel={}",
|
||||
destination, payload.get("type"), payload.get("status"), payload.get("channelName"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,35 +1,9 @@
|
||||
# ===================================================
|
||||
# Profile: prod (production)
|
||||
# Aktifkan dengan: --spring.profiles.active=prod
|
||||
# Semua nilai WAJIB diisi via environment variable
|
||||
# Tidak ada default value — akan gagal start jika kosong
|
||||
# ===================================================
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: ${DB_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
|
||||
jpa:
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: false
|
||||
|
||||
server:
|
||||
port: ${PORT:8080}
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: ${JWT_EXPIRATION:86400000}
|
||||
|
||||
agora:
|
||||
app-id: ${AGORA_APP_ID}
|
||||
app-certificate: ${AGORA_APP_CERTIFICATE}
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.walkguide: INFO
|
||||
org.springframework.messaging: WARN
|
||||
org.springframework.web.socket: WARN
|
||||
DB_URL=jdbc:postgresql://<host>:<port>/<database>
|
||||
DB_USERNAME=<database_username>
|
||||
DB_PASSWORD=<database_password>
|
||||
JWT_SECRET=<base64_hs256_secret_at_least_32_bytes>
|
||||
JWT_EXPIRATION=86400000
|
||||
AGORA_APP_ID=<agora_app_id>
|
||||
AGORA_APP_CERTIFICATE=<agora_app_certificate>
|
||||
FIREBASE_CREDENTIALS_PATH=classpath:firebase/google-services-admin.json
|
||||
FIREBASE_NOTIFICATIONS_COLLECTION=notifications
|
||||
|
||||
@ -6,9 +6,18 @@
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
||||
username: ${DB_USERNAME:5803024001}
|
||||
password: ${DB_PASSWORD:pw5803024001}
|
||||
url: ${DB_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
hikari:
|
||||
maximum-pool-size: ${DB_POOL_MAX:1}
|
||||
minimum-idle: ${DB_POOL_MIN_IDLE:0}
|
||||
connection-timeout: ${DB_CONNECTION_TIMEOUT:10000}
|
||||
idle-timeout: ${DB_IDLE_TIMEOUT:30000}
|
||||
max-lifetime: ${DB_MAX_LIFETIME:120000}
|
||||
|
||||
flyway:
|
||||
enabled: ${FLYWAY_ENABLED:true}
|
||||
|
||||
jpa:
|
||||
show-sql: true
|
||||
@ -17,12 +26,12 @@ spring:
|
||||
format_sql: true
|
||||
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt}
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: ${JWT_EXPIRATION:86400000}
|
||||
|
||||
agora:
|
||||
app-id: ${AGORA_APP_ID:}
|
||||
app-certificate: ${AGORA_APP_CERTIFICATE:}
|
||||
agora:
|
||||
app-id: ${AGORA_APP_ID:}
|
||||
app-certificate: ${AGORA_APP_CERTIFICATE:}
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
# ===== SERVER =====
|
||||
spring.config.import=optional:file:./secrets.properties,optional:file:walkguide-backend/demo/secrets.properties
|
||||
server.port=${SERVER_PORT:8080}
|
||||
server.address=${SERVER_ADDRESS:0.0.0.0}
|
||||
|
||||
# ===== POSTGRESQL CONNECTION =====
|
||||
spring.datasource.url=${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
||||
spring.datasource.username=${DB_USERNAME:5803024001}
|
||||
spring.datasource.password=${DB_PASSWORD:pw5803024001}
|
||||
spring.datasource.url=${DB_URL}
|
||||
spring.datasource.username=${DB_USERNAME}
|
||||
spring.datasource.password=${DB_PASSWORD}
|
||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
# ===== HIKARI POOL (keep DB classroom slots low) =====
|
||||
spring.datasource.hikari.maximum-pool-size=${DB_POOL_MAX:1}
|
||||
spring.datasource.hikari.minimum-idle=${DB_POOL_MIN_IDLE:0}
|
||||
spring.datasource.hikari.connection-timeout=${DB_CONNECTION_TIMEOUT:10000}
|
||||
spring.datasource.hikari.idle-timeout=${DB_IDLE_TIMEOUT:30000}
|
||||
spring.datasource.hikari.max-lifetime=${DB_MAX_LIFETIME:120000}
|
||||
|
||||
# ===== JPA / HIBERNATE =====
|
||||
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
|
||||
@ -19,7 +27,7 @@ spring.flyway.locations=classpath:db/migration
|
||||
spring.flyway.baseline-on-migrate=true
|
||||
|
||||
# ===== JWT =====
|
||||
jwt.secret=${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt}
|
||||
jwt.secret=${JWT_SECRET}
|
||||
jwt.expiration=${JWT_EXPIRATION:86400000}
|
||||
|
||||
# ===== SWAGGER =====
|
||||
@ -30,6 +38,10 @@ springdoc.api-docs.path=/v3/api-docs
|
||||
agora.app-id=${AGORA_APP_ID:}
|
||||
agora.app-certificate=${AGORA_APP_CERTIFICATE:}
|
||||
|
||||
# ===== FIREBASE =====
|
||||
firebase.credentials-path=${FIREBASE_CREDENTIALS_PATH:classpath:firebase/google-services-admin.json}
|
||||
firebase.notifications-collection=${FIREBASE_NOTIFICATIONS_COLLECTION:notifications}
|
||||
|
||||
# ===== WEBSOCKET =====
|
||||
# WebSocket auto-dikonfigurasi oleh WebSocketConfig.java
|
||||
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE location_history
|
||||
ADD COLUMN IF NOT EXISTS battery_level INTEGER;
|
||||
@ -4,6 +4,7 @@ import com.walkguide.dto.request.CallNotifyRequest;
|
||||
import com.walkguide.entity.User;
|
||||
import com.walkguide.exception.ResourceNotFoundException;
|
||||
import com.walkguide.repository.UserRepository;
|
||||
import com.walkguide.websocket.LocationBroadcaster;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@ -34,6 +35,9 @@ class CallNotificationServiceTest {
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private LocationBroadcaster locationBroadcaster;
|
||||
|
||||
@InjectMocks
|
||||
private CallNotificationService service;
|
||||
|
||||
@ -89,7 +93,7 @@ class CallNotificationServiceTest {
|
||||
|
||||
String message = service.notifyIncomingCall(1L, request);
|
||||
|
||||
assertEquals("Panggilan dikirim (receiver mungkin tidak menerima push notification)", message);
|
||||
assertEquals("Panggilan dikirim via realtime fallback.", message);
|
||||
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
|
||||
}
|
||||
|
||||
|
||||
@ -4,10 +4,11 @@ import com.walkguide.dto.request.SosRequest;
|
||||
import com.walkguide.dto.response.SosEventResponse;
|
||||
import com.walkguide.entity.PairingRelation;
|
||||
import com.walkguide.entity.SosEvent;
|
||||
import com.walkguide.entity.User;
|
||||
import com.walkguide.enums.PairingStatus;
|
||||
import com.walkguide.enums.SosStatus;
|
||||
import com.walkguide.exception.ResourceNotFoundException;
|
||||
import com.walkguide.entity.User;
|
||||
import com.walkguide.enums.PairingStatus;
|
||||
import com.walkguide.enums.SosStatus;
|
||||
import com.walkguide.exception.PairingException;
|
||||
import com.walkguide.exception.ResourceNotFoundException;
|
||||
import com.walkguide.repository.*;
|
||||
import com.walkguide.websocket.LocationBroadcaster;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@ -79,10 +80,10 @@ class SosServiceTest {
|
||||
req.setLat(-7.257);
|
||||
req.setLng(112.752);
|
||||
|
||||
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
||||
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
||||
.thenReturn(Optional.empty()); // tidak ada guardian → skip FCM
|
||||
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
||||
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
||||
.thenReturn(Optional.of(activePairing));
|
||||
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
|
||||
|
||||
SosEventResponse result = sosService.triggerSos(2L, req);
|
||||
@ -103,10 +104,10 @@ class SosServiceTest {
|
||||
req.setLat(-7.257);
|
||||
req.setLng(112.752);
|
||||
|
||||
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
||||
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
||||
.thenReturn(Optional.empty());
|
||||
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
||||
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
||||
.thenReturn(Optional.of(activePairing));
|
||||
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
|
||||
|
||||
ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class);
|
||||
@ -147,12 +148,27 @@ class SosServiceTest {
|
||||
SosRequest req = new SosRequest();
|
||||
req.setTriggerType("MANUAL");
|
||||
|
||||
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
|
||||
when(userRepository.findById(99L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> sosService.triggerSos(99L, req))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
when(userRepository.findById(99L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> sosService.triggerSos(99L, req))
|
||||
.isInstanceOf(ResourceNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("triggerSos - tanpa pairing aktif: harus throw PairingException dan tidak simpan SOS")
|
||||
void triggerSos_unpaired_shouldThrowPairingException() {
|
||||
SosRequest req = new SosRequest();
|
||||
req.setTriggerType("MANUAL");
|
||||
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
||||
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> sosService.triggerSos(2L, req))
|
||||
.isInstanceOf(PairingException.class)
|
||||
.hasMessageContaining("Guardian aktif");
|
||||
verify(sosEventRepository, never()).save(any(SosEvent.class));
|
||||
}
|
||||
|
||||
// ===== acknowledgeSos TESTS =====
|
||||
|
||||
|
||||
@ -5,6 +5,10 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
if (file("google-services.json").exists()) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.walkguide_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
@ -13,7 +16,9 @@
|
||||
<application
|
||||
android:label="WalkGuide"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@ -34,6 +39,9 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||
android:value="false" />
|
||||
</application>
|
||||
|
||||
<queries>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
@ -12,7 +12,7 @@
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">#F8FAFC</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
org.gradle.workers.max=2
|
||||
org.gradle.parallel=false
|
||||
org.gradle.daemon=false
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
kotlin.incremental=false
|
||||
kotlin.incremental=false
|
||||
@ -21,6 +21,7 @@ plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.9.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.google.gms.google-services") version "4.4.2" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0B132B"/>
|
||||
<stop offset="100%" stop-color="#1C2541"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="cyan-glow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#48CAE4"/>
|
||||
<stop offset="100%" stop-color="#0077B6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="amber-glow" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#FFB703"/>
|
||||
<stop offset="100%" stop-color="#FB8500"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#000000" flood-opacity="0.45"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="115" fill="url(#bg-grad)"/>
|
||||
<circle cx="256" cy="256" r="180" fill="none" stroke="#48CAE4" stroke-width="2" opacity="0.15" stroke-dasharray="10 14"/>
|
||||
<circle cx="256" cy="256" r="110" fill="none" stroke="#48CAE4" stroke-width="3" opacity="0.3"/>
|
||||
<path d="M 130 180 C 130 320, 180 370, 220 370 C 260 370, 256 270, 256 270"
|
||||
fill="none"
|
||||
stroke="url(#cyan-glow)"
|
||||
stroke-width="52"
|
||||
stroke-linecap="round"
|
||||
filter="url(#shadow)"/>
|
||||
<path d="M 382 180 C 382 320, 332 370, 292 370 C 252 370, 256 270, 256 270"
|
||||
fill="none"
|
||||
stroke="url(#amber-glow)"
|
||||
stroke-width="52"
|
||||
stroke-linecap="round"
|
||||
filter="url(#shadow)"/>
|
||||
<circle cx="256" cy="210" r="36" fill="#FFFFFF" filter="url(#shadow)"/>
|
||||
<circle cx="256" cy="210" r="14" fill="#0B132B"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@ -77,4 +77,4 @@ vase
|
||||
scissors
|
||||
teddy bear
|
||||
hair drier
|
||||
toothbrush
|
||||
toothbrush
|
||||
Binary file not shown.
@ -1,97 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
import 'app_cubit.dart';
|
||||
import 'router.dart';
|
||||
import '../core/i18n/app_strings.dart';
|
||||
import '../core/theme/app_colors.dart';
|
||||
import '../core/theme/app_decorations.dart';
|
||||
import '../core/theme/app_text_styles.dart';
|
||||
|
||||
class WalkGuideApp extends StatelessWidget {
|
||||
const WalkGuideApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const seed = Color(0xFF1A56DB);
|
||||
const seed = AppColors.primaryBlue;
|
||||
|
||||
return BlocProvider(
|
||||
create: (_) => AppCubit(),
|
||||
child: MaterialApp.router(
|
||||
title: 'WalkGuide',
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: appRouter,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFFF4F7FB),
|
||||
textTheme: GoogleFonts.interTextTheme(),
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: ZoomPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
|
||||
},
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: false,
|
||||
backgroundColor: Color(0xFFF4F7FB),
|
||||
foregroundColor: Color(0xFF0F172A),
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
elevation: 0,
|
||||
height: 76,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.96),
|
||||
indicatorColor: const Color(0xFFE0E7FF),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(states) => TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: states.contains(WidgetState.selected)
|
||||
? FontWeight.w800
|
||||
: FontWeight.w500,
|
||||
child: BlocBuilder<AppCubit, AppState>(
|
||||
builder: (context, state) => MaterialApp.router(
|
||||
title: 'WalkGuide',
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: appRouter,
|
||||
builder: (context, child) {
|
||||
final media = MediaQuery.of(context);
|
||||
return MediaQuery(
|
||||
data: media.copyWith(
|
||||
textScaler: media.textScaler.clamp(
|
||||
minScaleFactor: 0.9,
|
||||
maxScaleFactor: 1.15,
|
||||
),
|
||||
),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
locale: state.localeCode == 'en-US'
|
||||
? const Locale('en', 'US')
|
||||
: const Locale('id', 'ID'),
|
||||
supportedLocales: AppStrings.supportedLocales,
|
||||
localizationsDelegates: const [
|
||||
AppStringsDelegate(),
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
visualDensity: VisualDensity.adaptivePlatformDensity,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: Brightness.light,
|
||||
primary: seed,
|
||||
secondary: AppColors.accent,
|
||||
error: AppColors.danger,
|
||||
),
|
||||
scaffoldBackgroundColor: AppColors.surface,
|
||||
textTheme: AppTextStyles.textTheme.apply(
|
||||
bodyColor: AppColors.text,
|
||||
displayColor: AppColors.text,
|
||||
),
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: ZoomPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
|
||||
},
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: false,
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.text,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
cardTheme: const CardThemeData(
|
||||
elevation: 0,
|
||||
color: AppColors.surfaceRaised,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: AppDecorations.cardShape,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.border,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
iconButtonTheme: IconButtonThemeData(
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: AppColors.text,
|
||||
backgroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: seed,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(0, 50),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
elevation: 0,
|
||||
height: 76,
|
||||
backgroundColor: Colors.white,
|
||||
indicatorColor: AppColors.softBlueBg,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(states) => TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: states.contains(WidgetState.selected)
|
||||
? FontWeight.w800
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: seed,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(0, 50),
|
||||
textStyle: AppTextStyles.body.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 50),
|
||||
foregroundColor: seed,
|
||||
textStyle: AppTextStyles.body.copyWith(
|
||||
color: seed,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
),
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppColors.text,
|
||||
contentTextStyle: AppTextStyles.body.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 50),
|
||||
foregroundColor: seed,
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||
side: const BorderSide(color: Color(0xFFCBD5E1)),
|
||||
shape: RoundedRectangleBorder(
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: seed, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF8FAFC),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: seed, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -4,14 +4,26 @@ class AppState {
|
||||
final bool online;
|
||||
final String? role;
|
||||
final String? serverUrl;
|
||||
final String localeCode;
|
||||
|
||||
const AppState({required this.online, this.role, this.serverUrl});
|
||||
const AppState({
|
||||
required this.online,
|
||||
this.role,
|
||||
this.serverUrl,
|
||||
this.localeCode = 'id-ID',
|
||||
});
|
||||
|
||||
AppState copyWith({bool? online, String? role, String? serverUrl}) {
|
||||
AppState copyWith({
|
||||
bool? online,
|
||||
String? role,
|
||||
String? serverUrl,
|
||||
String? localeCode,
|
||||
}) {
|
||||
return AppState(
|
||||
online: online ?? this.online,
|
||||
role: role ?? this.role,
|
||||
serverUrl: serverUrl ?? this.serverUrl,
|
||||
localeCode: localeCode ?? this.localeCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -25,5 +37,7 @@ class AppCubit extends Cubit<AppState> {
|
||||
|
||||
void setOnline(bool value) => emit(state.copyWith(online: value));
|
||||
|
||||
void setLocaleCode(String value) => emit(state.copyWith(localeCode: value));
|
||||
|
||||
void clearSession() => emit(const AppState(online: true));
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../core/constants/app_constants.dart';
|
||||
import '../core/ai/obstacle_alert_strategy.dart';
|
||||
import '../core/ai/obstacle_analyzer.dart';
|
||||
@ -10,6 +8,7 @@ import '../core/services/haptic_service.dart';
|
||||
import '../core/services/call_service.dart';
|
||||
import '../core/services/fcm_service.dart';
|
||||
import '../core/services/hardware_shortcut_listener.dart';
|
||||
import '../core/services/incoming_call_polling_service.dart';
|
||||
import '../core/services/location_reporter_service.dart';
|
||||
import '../core/services/offline_queue_service.dart';
|
||||
import '../core/services/stt_service.dart';
|
||||
@ -18,7 +17,6 @@ import '../core/services/voice_command_handler.dart';
|
||||
import '../core/services/websocket_service.dart';
|
||||
import '../core/storage/local_database.dart';
|
||||
import '../core/storage/secure_storage.dart';
|
||||
import '../core/utils/init_guard.dart';
|
||||
import '../features/notifications/application/notification_cubit.dart';
|
||||
import '../features/notifications/data/repositories/notification_repository_impl.dart';
|
||||
import '../features/notifications/domain/repositories/notification_repository.dart';
|
||||
@ -39,17 +37,24 @@ Future<void> initDependencies() async {
|
||||
sl.registerLazySingleton<SttService>(() => SttService());
|
||||
sl.registerLazySingleton<HapticService>(() => HapticService());
|
||||
sl.registerLazySingleton<ObstacleAlertStrategy>(
|
||||
() => TtsWithHapticObstacleAlertStrategy(sl<TtsService>(), sl<HapticService>()),
|
||||
() => TtsWithHapticObstacleAlertStrategy(
|
||||
sl<TtsService>(), sl<HapticService>()),
|
||||
);
|
||||
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
|
||||
sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>()));
|
||||
sl.registerLazySingleton<YoloDetector>(
|
||||
() => YoloDetector(sl<ObstacleAnalyzer>()));
|
||||
sl.registerLazySingleton<OfflineQueueService>(
|
||||
() => OfflineQueueService(sl<LocalDatabase>()),
|
||||
);
|
||||
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
|
||||
sl.registerLazySingleton<WebSocketService>(() => WebSocketService(sl<SecureStorage>()));
|
||||
sl.registerLazySingleton<LocationReporterService>(() => LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
|
||||
sl.registerLazySingleton<WebSocketService>(
|
||||
() => WebSocketService(sl<SecureStorage>()));
|
||||
sl.registerLazySingleton<LocationReporterService>(() =>
|
||||
LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
|
||||
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
|
||||
sl.registerLazySingleton<IncomingCallPollingService>(
|
||||
() => IncomingCallPollingService(sl<ApiClient>()),
|
||||
);
|
||||
sl.registerLazySingleton<HardwareShortcutListener>(
|
||||
() => HardwareShortcutListener(sl<ApiClient>()),
|
||||
);
|
||||
@ -59,8 +64,10 @@ Future<void> initDependencies() async {
|
||||
sl.registerLazySingleton<WalkGuideRepository>(
|
||||
() => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()),
|
||||
);
|
||||
sl.registerFactory<WalkGuideCubit>(() => WalkGuideCubit(sl<WalkGuideRepository>()));
|
||||
sl.registerLazySingleton<SosRepository>(() => SosRepositoryImpl(sl<ApiClient>()));
|
||||
sl.registerFactory<WalkGuideCubit>(
|
||||
() => WalkGuideCubit(sl<WalkGuideRepository>()));
|
||||
sl.registerLazySingleton<SosRepository>(
|
||||
() => SosRepositoryImpl(sl<ApiClient>()));
|
||||
sl.registerFactory<SosCubit>(() => SosCubit(sl<SosRepository>()));
|
||||
sl.registerLazySingleton<NotificationRepository>(
|
||||
() => NotificationRepositoryImpl(sl<ApiClient>(), sl<LocalDatabase>()),
|
||||
@ -74,13 +81,5 @@ Future<void> initDependencies() async {
|
||||
await sl<ApiClient>().init(serverUrl);
|
||||
}
|
||||
|
||||
await ignoreInitFailure(() => sl<TtsService>().init(), label: 'TTS init');
|
||||
await sl<YoloDetector>().init();
|
||||
if (!kIsWeb) {
|
||||
await ignoreInitFailure(() => sl<SttService>().init(), label: 'STT init');
|
||||
}
|
||||
sl<VoiceCommandHandler>().loadDefaultCommands();
|
||||
if (!kIsWeb) {
|
||||
await sl<FcmService>().init();
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,11 +25,13 @@ import '../features/guardian_dashboard/presentation/screens/guardian_tools_scree
|
||||
as guardian_tools;
|
||||
import '../features/home/presentation/guardian_dashboard_screen.dart'
|
||||
as guardian_home;
|
||||
import '../features/manual/manual_screen.dart' as manual;
|
||||
import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart'
|
||||
as nav;
|
||||
import '../features/notifications/presentation/screens/notification_screen.dart'
|
||||
as notifications;
|
||||
import '../features/pairing/presentation/screens/pairing_screens.dart' as pairing;
|
||||
import '../features/pairing/presentation/screens/pairing_screens.dart'
|
||||
as pairing;
|
||||
import '../features/server_connect/server_connect_server.dart'
|
||||
as server_connect;
|
||||
import '../features/settings/presentation/screens/user_settings_screen.dart'
|
||||
@ -40,10 +42,12 @@ import '../features/walk_guide/presentation/screens/walk_guide_screen.dart'
|
||||
import '../shared/widgets/app_shells.dart';
|
||||
|
||||
final GoRouter appRouter = GoRouter(
|
||||
initialLocation: '/splash',
|
||||
initialLocation: '/server-connect',
|
||||
redirect: (context, state) async {
|
||||
final path = state.matchedLocation;
|
||||
final serverUrl = await AppConstants.getServerUrl();
|
||||
final isEditingServer =
|
||||
path == '/server-connect' && state.uri.queryParameters['edit'] == '1';
|
||||
final isPublicRoute = path == '/server-connect' ||
|
||||
path == '/splash' ||
|
||||
path == '/login' ||
|
||||
@ -52,7 +56,8 @@ final GoRouter appRouter = GoRouter(
|
||||
if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') {
|
||||
return '/server-connect';
|
||||
}
|
||||
if (path == '/server-connect' &&
|
||||
if (!isEditingServer &&
|
||||
path == '/server-connect' &&
|
||||
serverUrl != null &&
|
||||
serverUrl.isNotEmpty) {
|
||||
return '/splash';
|
||||
@ -87,7 +92,9 @@ final GoRouter appRouter = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/server-connect',
|
||||
builder: (_, __) => const server_connect.ServerConnectScreen()),
|
||||
builder: (_, state) => server_connect.ServerConnectScreen(
|
||||
editMode: state.uri.queryParameters['edit'] == '1',
|
||||
)),
|
||||
GoRoute(
|
||||
path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
|
||||
GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()),
|
||||
@ -96,7 +103,18 @@ final GoRouter appRouter = GoRouter(
|
||||
builder: (_, __) => const auth_register.RegisterScreen()),
|
||||
GoRoute(
|
||||
path: '/incoming-call',
|
||||
builder: (_, __) => const call.IncomingCallScreen()),
|
||||
builder: (_, state) {
|
||||
final extra = state.extra is Map
|
||||
? Map<String, dynamic>.from(state.extra as Map)
|
||||
: <String, dynamic>{};
|
||||
return call.IncomingCallScreen(
|
||||
callerName: extra['callerName']?.toString() ?? 'Guardian',
|
||||
callerId: int.tryParse(extra['callerId']?.toString() ?? ''),
|
||||
channelName: extra['channelName']?.toString(),
|
||||
agoraToken: extra['agoraToken']?.toString(),
|
||||
agoraAppId: extra['agoraAppId']?.toString(),
|
||||
);
|
||||
}),
|
||||
ShellRoute(
|
||||
builder: (_, __, child) => UserShell(child: child),
|
||||
routes: [
|
||||
@ -124,6 +142,9 @@ final GoRouter appRouter = GoRouter(
|
||||
GoRoute(
|
||||
path: '/user/benchmark',
|
||||
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
||||
GoRoute(
|
||||
path: '/user/manual',
|
||||
builder: (_, __) => const manual.ManualScreen()),
|
||||
],
|
||||
),
|
||||
ShellRoute(
|
||||
@ -161,6 +182,12 @@ final GoRouter appRouter = GoRouter(
|
||||
path: '/guardian/settings',
|
||||
builder: (_, __) =>
|
||||
const guardian_settings.GuardianSettingsScreen()),
|
||||
GoRoute(
|
||||
path: '/guardian/call',
|
||||
builder: (_, __) => const call.CallScreen(
|
||||
targetLabel: 'User',
|
||||
returnRoute: '/guardian/dashboard',
|
||||
)),
|
||||
GoRoute(
|
||||
path: '/guardian/benchmark',
|
||||
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
||||
|
||||
@ -586,14 +586,31 @@ const Set<String> _walkGuideObstacleLabels = {
|
||||
'bicycle',
|
||||
'car',
|
||||
'motorcycle',
|
||||
'truck',
|
||||
'bus',
|
||||
'train',
|
||||
'truck',
|
||||
'boat',
|
||||
'traffic light',
|
||||
'fire hydrant',
|
||||
'stop sign',
|
||||
'parking meter',
|
||||
'bench',
|
||||
'stairs',
|
||||
'stair',
|
||||
'pothole',
|
||||
'curb',
|
||||
'pole',
|
||||
'bollard',
|
||||
'cone',
|
||||
'road cone',
|
||||
'barrier',
|
||||
'fence',
|
||||
'door',
|
||||
'trash can',
|
||||
'signboard',
|
||||
'crosswalk',
|
||||
'sidewalk',
|
||||
'wall',
|
||||
'backpack',
|
||||
'umbrella',
|
||||
'handbag',
|
||||
@ -608,6 +625,7 @@ const Set<String> _walkGuideObstacleLabels = {
|
||||
'bottle',
|
||||
'cup',
|
||||
'book',
|
||||
'object',
|
||||
};
|
||||
|
||||
const Map<int, String> _cocoObstacleLabels = {
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AppConstants {
|
||||
static const String defaultServerUrl = 'http://127.0.0.1:8080';
|
||||
static const String publicServerUrl = 'http://202.46.28.170:8080';
|
||||
static const String _serverUrlKey = 'server_base_url';
|
||||
static const String _selectedYoloModelKey = 'selected_yolo_model';
|
||||
|
||||
// Ambil base URL dari SharedPreferences
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_serverUrlKey);
|
||||
final saved = prefs.getString(_serverUrlKey);
|
||||
if (saved == null || saved.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
// Simpan URL setelah berhasil connect
|
||||
@ -22,6 +28,9 @@ class AppConstants {
|
||||
if (!cleaned.startsWith('http://') && !cleaned.startsWith('https://')) {
|
||||
cleaned = 'http://$cleaned';
|
||||
}
|
||||
cleaned = cleaned
|
||||
.replaceFirst('://localhost', '://127.0.0.1')
|
||||
.replaceFirst('://0.0.0.0', '://127.0.0.1');
|
||||
while (cleaned.endsWith('/')) {
|
||||
cleaned = cleaned.substring(0, cleaned.length - 1);
|
||||
}
|
||||
@ -61,7 +70,6 @@ class AppConstants {
|
||||
await prefs.setString(_selectedYoloModelKey, path);
|
||||
}
|
||||
|
||||
// Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=...
|
||||
static const String agoraAppId =
|
||||
String.fromEnvironment('AGORA_APP_ID', defaultValue: '');
|
||||
// Set at build time with: --dart-define=AGORA_APP_ID=your_app_id
|
||||
static const String agoraAppId = String.fromEnvironment('AGORA_APP_ID');
|
||||
}
|
||||
|
||||
@ -71,6 +71,12 @@ bool _looksTechnical(String message) {
|
||||
'null check operator',
|
||||
'nosuchmethod',
|
||||
'formatexception',
|
||||
'could not execute statement',
|
||||
'duplicate key',
|
||||
'constraint',
|
||||
'sql [',
|
||||
'illegal base64',
|
||||
'base64 character',
|
||||
];
|
||||
return blocked.any(lower.contains);
|
||||
}
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class AppStrings {
|
||||
final String localeCode;
|
||||
|
||||
const AppStrings(this.localeCode);
|
||||
|
||||
static const supportedLocales = ['id-ID', 'en-US'];
|
||||
static const supportedLocales = [
|
||||
Locale('id', 'ID'),
|
||||
Locale('en', 'US'),
|
||||
];
|
||||
|
||||
static AppStrings of(BuildContext context) {
|
||||
return Localizations.of<AppStrings>(context, AppStrings) ??
|
||||
const AppStrings('id-ID');
|
||||
}
|
||||
|
||||
String get walkGuideStarted => _pick(
|
||||
id: 'WalkGuide dimulai',
|
||||
@ -29,3 +39,21 @@ class AppStrings {
|
||||
return localeCode == 'en-US' ? en : id;
|
||||
}
|
||||
}
|
||||
|
||||
class AppStringsDelegate extends LocalizationsDelegate<AppStrings> {
|
||||
const AppStringsDelegate();
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) {
|
||||
return locale.languageCode == 'id' || locale.languageCode == 'en';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AppStrings> load(Locale locale) async {
|
||||
final code = locale.languageCode == 'en' ? 'en-US' : 'id-ID';
|
||||
return AppStrings(code);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReload(covariant LocalizationsDelegate<AppStrings> old) => false;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
@ -24,8 +25,15 @@ class ApiClient {
|
||||
_dio.interceptors.addAll([
|
||||
_AuthInterceptor(_secureStorage, _dio),
|
||||
_ErrorInterceptor(),
|
||||
LogInterceptor(requestBody: true, responseBody: true),
|
||||
]);
|
||||
if (kDebugMode) {
|
||||
_dio.interceptors.add(LogInterceptor(
|
||||
requestBody: false,
|
||||
responseBody: false,
|
||||
requestHeader: false,
|
||||
responseHeader: false,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Dio get dio => _dio;
|
||||
@ -42,7 +50,8 @@ class _AuthInterceptor extends Interceptor {
|
||||
_AuthInterceptor(this._storage, this._dio);
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
void onRequest(
|
||||
RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
final token = await _storage.getAccessToken();
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
@ -52,7 +61,11 @@ class _AuthInterceptor extends Interceptor {
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (err.response?.statusCode == 401 && !_refreshing) {
|
||||
final status = err.response?.statusCode;
|
||||
final canRefresh = (status == 401 || status == 403) &&
|
||||
!_refreshing &&
|
||||
!err.requestOptions.path.startsWith('/auth/');
|
||||
if (canRefresh) {
|
||||
_refreshing = true;
|
||||
try {
|
||||
final refresh = await _storage.getRefreshToken();
|
||||
@ -78,14 +91,20 @@ class _AuthInterceptor extends Interceptor {
|
||||
// Retry original request
|
||||
err.requestOptions.headers['Authorization'] =
|
||||
'Bearer ${data['accessToken']}';
|
||||
final retryRes = await _dio.fetch(err.requestOptions);
|
||||
_refreshing = false;
|
||||
handler.resolve(retryRes);
|
||||
try {
|
||||
final retryRes = await _dio.fetch(err.requestOptions);
|
||||
_refreshing = false;
|
||||
handler.resolve(retryRes);
|
||||
} on DioException catch (retryErr) {
|
||||
_refreshing = false;
|
||||
handler.next(retryErr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
await _storage.clearAll();
|
||||
}
|
||||
_refreshing = false;
|
||||
await _storage.clearAll();
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
import '../network/api_client.dart';
|
||||
@ -7,9 +10,19 @@ import '../network/api_client.dart';
|
||||
class CallService {
|
||||
final ApiClient _apiClient;
|
||||
RtcEngine? _engine;
|
||||
VoidCallback? _onRemoteUserJoined;
|
||||
VoidCallback? _onRemoteUserOffline;
|
||||
|
||||
CallService(this._apiClient);
|
||||
|
||||
void setRemoteUserJoinedCallback(VoidCallback? callback) {
|
||||
_onRemoteUserJoined = callback;
|
||||
}
|
||||
|
||||
void setRemoteUserOfflineCallback(VoidCallback? callback) {
|
||||
_onRemoteUserOffline = callback;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> requestToken({required int receiverId}) async {
|
||||
final res = await _apiClient.dio.post(
|
||||
'/shared/call/token',
|
||||
@ -31,72 +44,219 @@ class CallService {
|
||||
required int receiverId,
|
||||
required String channelName,
|
||||
String? agoraToken,
|
||||
String? agoraAppId,
|
||||
int receiverUid = 0,
|
||||
}) async {
|
||||
await _apiClient.dio.post('/shared/call/notify', data: {
|
||||
'receiverId': receiverId,
|
||||
'channelName': channelName,
|
||||
'agoraToken': agoraToken,
|
||||
'receiverUid': receiverUid,
|
||||
});
|
||||
await _apiClient.dio.post(
|
||||
'/shared/call/notify',
|
||||
data: {
|
||||
'receiverId': receiverId,
|
||||
'channelName': channelName,
|
||||
'agoraToken': agoraToken,
|
||||
'agoraAppId': agoraAppId,
|
||||
'receiverUid': receiverUid,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> callPairedUser({int uid = 0}) async {
|
||||
Future<Map<String, dynamic>?> startPairedCall({int uid = 0}) async {
|
||||
final receiverId = await getPairedReceiverId();
|
||||
if (receiverId == null) return false;
|
||||
if (receiverId == null) return null;
|
||||
|
||||
final tokenData = await requestToken(receiverId: receiverId);
|
||||
final channelName = tokenData?['channelName']?.toString();
|
||||
final token = tokenData?['token']?.toString();
|
||||
if (channelName == null || channelName.isEmpty) return false;
|
||||
final appId = tokenData?['appId']?.toString();
|
||||
final localUid = (tokenData?['uid'] as num?)?.toInt() ?? uid;
|
||||
if (channelName == null || channelName.isEmpty) return null;
|
||||
|
||||
final joined = await joinChannel(
|
||||
channelName: channelName,
|
||||
token: token,
|
||||
uid: uid,
|
||||
appId: appId,
|
||||
uid: localUid,
|
||||
);
|
||||
if (!joined) return null;
|
||||
|
||||
await notifyIncomingCall(
|
||||
receiverId: receiverId,
|
||||
channelName: channelName,
|
||||
agoraToken: token,
|
||||
agoraAppId: appId,
|
||||
receiverUid: 0,
|
||||
);
|
||||
|
||||
return {
|
||||
'receiverId': receiverId,
|
||||
'channelName': channelName,
|
||||
'token': token,
|
||||
'uid': localUid,
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> callPairedUser({int uid = 0}) async {
|
||||
return await startPairedCall(uid: uid) != null;
|
||||
}
|
||||
|
||||
Future<void> acceptIncomingCall({
|
||||
required int callerId,
|
||||
required String channelName,
|
||||
}) async {
|
||||
await _apiClient.dio.post(
|
||||
'/shared/call/accept',
|
||||
data: {'callerId': callerId.toString(), 'channelName': channelName},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getAcceptedCall() async {
|
||||
final res = await _apiClient.dio.get('/shared/call/accepted');
|
||||
final data = res.data['data'];
|
||||
return data is Map ? Map<String, dynamic>.from(data) : null;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getCallState(String? channelName) async {
|
||||
if (channelName == null || channelName.isEmpty) return null;
|
||||
final res = await _apiClient.dio.get(
|
||||
'/shared/call/state',
|
||||
queryParameters: {'channelName': channelName},
|
||||
);
|
||||
final data = res.data['data'];
|
||||
return data is Map ? Map<String, dynamic>.from(data) : null;
|
||||
}
|
||||
|
||||
Future<void> clearAcceptedCall() async {
|
||||
await _apiClient.dio.delete('/shared/call/accepted');
|
||||
}
|
||||
|
||||
Future<void> clearPendingCall() async {
|
||||
await _apiClient.dio.delete('/shared/call/pending');
|
||||
}
|
||||
|
||||
Future<void> endCall(int? otherId, {String? channelName}) async {
|
||||
if (otherId == null) return;
|
||||
await _apiClient.dio.post(
|
||||
'/shared/call/end',
|
||||
data: {
|
||||
'otherId': otherId.toString(),
|
||||
if (channelName != null && channelName.isNotEmpty)
|
||||
'channelName': channelName,
|
||||
},
|
||||
);
|
||||
if (joined) {
|
||||
await notifyIncomingCall(
|
||||
receiverId: receiverId,
|
||||
channelName: channelName,
|
||||
agoraToken: token,
|
||||
receiverUid: uid,
|
||||
);
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
Future<bool> joinChannel({
|
||||
required String channelName,
|
||||
String? token,
|
||||
String? appId,
|
||||
int uid = 0,
|
||||
}) async {
|
||||
final joinCompleter = Completer<bool>();
|
||||
try {
|
||||
if (AppConstants.agoraAppId.isEmpty) {
|
||||
final resolvedAppId =
|
||||
(appId != null && appId.isNotEmpty) ? appId : AppConstants.agoraAppId;
|
||||
if (resolvedAppId.isEmpty) {
|
||||
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
|
||||
return false;
|
||||
}
|
||||
if (!await _ensureMicrophonePermission()) {
|
||||
debugPrint('Agora join skipped: microphone permission denied');
|
||||
return false;
|
||||
}
|
||||
|
||||
_engine ??= createAgoraRtcEngine();
|
||||
await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId));
|
||||
await _engine!.initialize(RtcEngineContext(appId: resolvedAppId));
|
||||
_engine!.registerEventHandler(
|
||||
RtcEngineEventHandler(
|
||||
onJoinChannelSuccess: (_, __) {
|
||||
if (!joinCompleter.isCompleted) joinCompleter.complete(true);
|
||||
},
|
||||
onUserJoined: (_, remoteUid, __) {
|
||||
debugPrint('Agora remote user joined: $remoteUid');
|
||||
_onRemoteUserJoined?.call();
|
||||
},
|
||||
onUserOffline: (_, remoteUid, reason) {
|
||||
debugPrint('Agora remote user offline: $remoteUid $reason');
|
||||
_onRemoteUserOffline?.call();
|
||||
},
|
||||
onRemoteAudioStateChanged: (_, remoteUid, state, reason, __) {
|
||||
debugPrint(
|
||||
'Agora remote audio state: uid=$remoteUid state=$state reason=$reason',
|
||||
);
|
||||
},
|
||||
onError: (type, msg) {
|
||||
debugPrint('Agora error: $type $msg');
|
||||
if (!joinCompleter.isCompleted) joinCompleter.complete(false);
|
||||
},
|
||||
),
|
||||
);
|
||||
await _engine!.setChannelProfile(
|
||||
ChannelProfileType.channelProfileCommunication,
|
||||
);
|
||||
await _engine!.setClientRole(role: ClientRoleType.clientRoleBroadcaster);
|
||||
await _engine!.setAudioProfile(
|
||||
profile: AudioProfileType.audioProfileDefault,
|
||||
scenario: AudioScenarioType.audioScenarioDefault,
|
||||
);
|
||||
await _engine!.enableAudio();
|
||||
await _engine!.enableLocalAudio(true);
|
||||
await _engine!.muteAllRemoteAudioStreams(false);
|
||||
await _engine!.muteLocalAudioStream(false);
|
||||
await _engine!.enableAudioVolumeIndication(
|
||||
interval: 500,
|
||||
smooth: 3,
|
||||
reportVad: true,
|
||||
);
|
||||
await _engine!.adjustRecordingSignalVolume(100);
|
||||
await _engine!.adjustPlaybackSignalVolume(100);
|
||||
await _engine!.setDefaultAudioRouteToSpeakerphone(true);
|
||||
await _engine!.setEnableSpeakerphone(true);
|
||||
await _engine!.joinChannel(
|
||||
token: token ?? '',
|
||||
channelId: channelName,
|
||||
uid: uid,
|
||||
options: const ChannelMediaOptions(),
|
||||
options: const ChannelMediaOptions(
|
||||
channelProfile: ChannelProfileType.channelProfileCommunication,
|
||||
clientRoleType: ClientRoleType.clientRoleBroadcaster,
|
||||
publishMicrophoneTrack: true,
|
||||
autoSubscribeAudio: true,
|
||||
),
|
||||
);
|
||||
return joinCompleter.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
debugPrint('Agora join timeout for channel $channelName');
|
||||
return false;
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Agora join skipped: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _ensureMicrophonePermission() async {
|
||||
if (kIsWeb) return true;
|
||||
final status = await Permission.microphone.request();
|
||||
return status.isGranted || status.isLimited;
|
||||
}
|
||||
|
||||
Future<void> setMuted(bool muted) async {
|
||||
await _engine?.muteLocalAudioStream(muted);
|
||||
}
|
||||
|
||||
Future<void> setSpeakerEnabled(bool enabled) async {
|
||||
await _engine?.setEnableSpeakerphone(enabled);
|
||||
}
|
||||
|
||||
Future<void> leave() async {
|
||||
_onRemoteUserJoined = null;
|
||||
_onRemoteUserOffline = null;
|
||||
await _engine?.leaveChannel();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_onRemoteUserJoined = null;
|
||||
_onRemoteUserOffline = null;
|
||||
await _engine?.release();
|
||||
_engine = null;
|
||||
}
|
||||
|
||||
@ -1,32 +1,62 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../../app/router.dart';
|
||||
import '../network/api_client.dart';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
await Firebase.initializeApp();
|
||||
}
|
||||
|
||||
class FcmService {
|
||||
final ApiClient _apiClient;
|
||||
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
|
||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
FcmService(this._apiClient);
|
||||
|
||||
Future<void> init() async {
|
||||
if (kIsWeb) return;
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
FirebaseMessaging.onBackgroundMessage(
|
||||
_firebaseMessagingBackgroundHandler);
|
||||
final messaging = FirebaseMessaging.instance;
|
||||
await _localNotifications.initialize(
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
),
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
final payload = response.payload;
|
||||
if (payload == null || payload.isEmpty) return;
|
||||
try {
|
||||
final data = Map<String, dynamic>.from(jsonDecode(payload) as Map);
|
||||
_handlePayloadNavigation(data);
|
||||
} catch (_) {}
|
||||
},
|
||||
);
|
||||
await _messaging.requestPermission(alert: true, badge: true, sound: true);
|
||||
final token = await _messaging.getToken();
|
||||
await messaging.requestPermission(alert: true, badge: true, sound: true);
|
||||
final token = await messaging.getToken();
|
||||
if (token != null) await syncToken(token);
|
||||
FirebaseMessaging.instance.onTokenRefresh.listen(syncToken);
|
||||
messaging.onTokenRefresh.listen(syncToken);
|
||||
FirebaseMessaging.onMessage.listen((message) {
|
||||
debugPrint('FCM foreground: ${message.data}');
|
||||
_showLocalNotification(message);
|
||||
_handlePayloadNavigation(message.data);
|
||||
});
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((message) {
|
||||
_handlePayloadNavigation(message.data);
|
||||
});
|
||||
final initialMessage =
|
||||
await FirebaseMessaging.instance.getInitialMessage();
|
||||
if (initialMessage != null) {
|
||||
_handlePayloadNavigation(initialMessage.data);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('FCM init skipped: $e');
|
||||
}
|
||||
@ -34,6 +64,10 @@ class FcmService {
|
||||
|
||||
Future<void> syncToken(String token) async {
|
||||
try {
|
||||
if (_apiClient.baseUrl == null) {
|
||||
debugPrint('FCM token sync skipped: server URL is not ready.');
|
||||
return;
|
||||
}
|
||||
await _apiClient.dio.put('/auth/fcm-token', data: {'fcmToken': token});
|
||||
} catch (e) {
|
||||
debugPrint('FCM token sync skipped: $e');
|
||||
@ -42,8 +76,11 @@ class FcmService {
|
||||
|
||||
Future<void> _showLocalNotification(RemoteMessage message) async {
|
||||
final notification = message.notification;
|
||||
final title = notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
|
||||
final body = notification?.body ?? message.data['body']?.toString() ?? 'Ada update baru';
|
||||
final title =
|
||||
notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
|
||||
final body = notification?.body ??
|
||||
message.data['body']?.toString() ??
|
||||
'Ada update baru';
|
||||
await _localNotifications.show(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
title,
|
||||
@ -57,7 +94,26 @@ class FcmService {
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
payload: message.data['type']?.toString(),
|
||||
payload: jsonEncode(message.data),
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePayloadNavigation(Map<String, dynamic> data) {
|
||||
final type = data['type']?.toString();
|
||||
if (type == 'INCOMING_CALL') {
|
||||
appRouter.go('/incoming-call', extra: data);
|
||||
return;
|
||||
}
|
||||
if (type == 'SOS_ALERT') {
|
||||
appRouter.go('/guardian/dashboard');
|
||||
return;
|
||||
}
|
||||
if (type == 'PAIRING_INVITE' || type == 'PAIRING_RESPONSE') {
|
||||
appRouter.go('/user/pairing');
|
||||
return;
|
||||
}
|
||||
if (type == 'NOTIFICATION') {
|
||||
appRouter.go('/user/notifications');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,36 +1,107 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:vibration/vibration.dart';
|
||||
|
||||
class HapticService {
|
||||
Future<bool> get _hasVibrator async => Vibration.hasVibrator();
|
||||
bool _enabled = true;
|
||||
DateTime _lastObstacleVibrationAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
static const _obstacleCooldown = Duration(seconds: 3);
|
||||
|
||||
bool get enabled => _enabled;
|
||||
|
||||
void setEnabled(bool enabled) {
|
||||
_enabled = enabled;
|
||||
if (!enabled) {
|
||||
Vibration.cancel().ignore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> get _hasVibrator async {
|
||||
try {
|
||||
final hasVibrator = await Vibration.hasVibrator();
|
||||
return hasVibrator == true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _canRunObstacleVibration() {
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_lastObstacleVibrationAt) < _obstacleCooldown) {
|
||||
return false;
|
||||
}
|
||||
_lastObstacleVibrationAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _vibrate({
|
||||
int? duration,
|
||||
List<int>? pattern,
|
||||
required Future<void> Function() fallback,
|
||||
bool obstacle = false,
|
||||
}) async {
|
||||
if (!_enabled) return;
|
||||
if (obstacle && !_canRunObstacleVibration()) return;
|
||||
|
||||
try {
|
||||
if (await _hasVibrator) {
|
||||
if (pattern != null) {
|
||||
await Vibration.vibrate(pattern: pattern);
|
||||
} else if (duration != null) {
|
||||
await Vibration.vibrate(duration: duration);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// Use Flutter's platform haptics below when the vibration plugin fails.
|
||||
}
|
||||
|
||||
await fallback();
|
||||
}
|
||||
|
||||
Future<void> obstacleVeryClose() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(pattern: [0, 500, 100, 500, 100, 500]);
|
||||
await _vibrate(
|
||||
pattern: [0, 500, 100, 500, 100, 500],
|
||||
fallback: HapticFeedback.heavyImpact,
|
||||
obstacle: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> obstacleClose() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(pattern: [0, 300, 100, 300]);
|
||||
await _vibrate(
|
||||
pattern: [0, 300, 100, 300],
|
||||
fallback: HapticFeedback.mediumImpact,
|
||||
obstacle: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> obstacleMedium() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(duration: 150);
|
||||
await _vibrate(
|
||||
duration: 150,
|
||||
fallback: HapticFeedback.lightImpact,
|
||||
obstacle: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sosTriggered() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(pattern: [0, 1000, 200, 1000, 200, 1000]);
|
||||
await _vibrate(
|
||||
pattern: [0, 1000, 200, 1000, 200, 1000],
|
||||
fallback: HapticFeedback.heavyImpact,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> callIncoming() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(pattern: [0, 500, 500, 500, 500, 500, 500, 500]);
|
||||
await _vibrate(
|
||||
pattern: [0, 500, 500, 500, 500, 500, 500, 500],
|
||||
fallback: HapticFeedback.mediumImpact,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> success() async {
|
||||
if (!await _hasVibrator) return;
|
||||
Vibration.vibrate(duration: 80);
|
||||
await _vibrate(
|
||||
duration: 80,
|
||||
fallback: HapticFeedback.selectionClick,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stop() async => Vibration.cancel();
|
||||
|
||||
@ -27,11 +27,14 @@ class HardwareShortcutBinding {
|
||||
class HardwareShortcutListener {
|
||||
final ApiClient _apiClient;
|
||||
final Map<int, HardwareShortcutBinding> _bindings = {};
|
||||
final Map<int, DateTime> _lastHandledAt = {};
|
||||
|
||||
bool _listening = false;
|
||||
void Function(HardwareShortcutAction action)? _onAction;
|
||||
void Function(int buttonCode, String buttonName)? _captureCallback;
|
||||
|
||||
static const Duration _repeatDebounce = Duration(milliseconds: 900);
|
||||
|
||||
HardwareShortcutListener(this._apiClient);
|
||||
|
||||
Future<void> startListening({
|
||||
@ -68,7 +71,8 @@ class HardwareShortcutListener {
|
||||
);
|
||||
}
|
||||
|
||||
void captureNextButton(void Function(int buttonCode, String buttonName) onCapture) {
|
||||
void captureNextButton(
|
||||
void Function(int buttonCode, String buttonName) onCapture) {
|
||||
_captureCallback = onCapture;
|
||||
}
|
||||
|
||||
@ -88,6 +92,12 @@ class HardwareShortcutListener {
|
||||
|
||||
final binding = _bindings[code];
|
||||
if (binding == null || !binding.enabled) return false;
|
||||
final now = DateTime.now();
|
||||
final lastHandled = _lastHandledAt[code];
|
||||
if (lastHandled != null && now.difference(lastHandled) < _repeatDebounce) {
|
||||
return true;
|
||||
}
|
||||
_lastHandledAt[code] = now;
|
||||
_onAction?.call(binding.action);
|
||||
return true;
|
||||
}
|
||||
@ -103,7 +113,8 @@ HardwareShortcutBinding? _bindingFromJson(Map<String, dynamic> item) {
|
||||
final action = _actionFromBackend(item['shortcutKey']?.toString());
|
||||
final rawCode = item['buttonCode'];
|
||||
final enabled = item['enabled'] != false;
|
||||
final code = rawCode is int ? rawCode : int.tryParse(rawCode?.toString() ?? '');
|
||||
final code =
|
||||
rawCode is int ? rawCode : int.tryParse(rawCode?.toString() ?? '');
|
||||
if (action == null || code == null || code <= 0) return null;
|
||||
return HardwareShortcutBinding(
|
||||
action: action,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:battery_plus/battery_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
@ -10,6 +11,7 @@ import 'offline_queue_service.dart';
|
||||
class LocationReporterService {
|
||||
final ApiClient _apiClient;
|
||||
final OfflineQueueService _offlineQueue;
|
||||
final Battery _battery = Battery();
|
||||
Timer? _timer;
|
||||
|
||||
LocationReporterService(this._apiClient, this._offlineQueue);
|
||||
@ -32,12 +34,14 @@ class LocationReporterService {
|
||||
try {
|
||||
await Geolocator.requestPermission();
|
||||
final position = await Geolocator.getCurrentPosition();
|
||||
final batteryLevel = await _readBatteryLevel();
|
||||
await _apiClient.dio.post('/user/location', data: {
|
||||
'lat': position.latitude,
|
||||
'lng': position.longitude,
|
||||
'accuracy': position.accuracy,
|
||||
'speed': position.speed,
|
||||
'heading': position.heading,
|
||||
if (batteryLevel != null) 'batteryLevel': batteryLevel,
|
||||
});
|
||||
} on DioException catch (_) {
|
||||
await _offlineQueue.enqueue(OfflineRequest(
|
||||
@ -50,4 +54,12 @@ class LocationReporterService {
|
||||
// GPS permission can be unavailable during desktop/web testing.
|
||||
}
|
||||
}
|
||||
|
||||
Future<int?> _readBatteryLevel() async {
|
||||
try {
|
||||
return await _battery.batteryLevel;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,17 +6,24 @@ class SttService {
|
||||
final SpeechToText _stt = SpeechToText();
|
||||
bool _available = false;
|
||||
bool _listening = false;
|
||||
bool _shouldListen = false;
|
||||
bool _initializing = false;
|
||||
Function(String)? onResult;
|
||||
|
||||
Future<bool> init() async {
|
||||
if (_available) return true;
|
||||
if (_initializing) return _available;
|
||||
_initializing = true;
|
||||
_available = await _stt.initialize(
|
||||
onError: (e) => _onError(e),
|
||||
onStatus: (s) => _onStatus(s),
|
||||
);
|
||||
_initializing = false;
|
||||
return _available;
|
||||
}
|
||||
|
||||
Future<void> startListening() async {
|
||||
_shouldListen = true;
|
||||
if (!_available || _listening) return;
|
||||
_listening = true;
|
||||
await _stt.listen(
|
||||
@ -25,14 +32,15 @@ class SttService {
|
||||
onResult?.call(result.recognizedWords.toLowerCase().trim());
|
||||
}
|
||||
},
|
||||
listenFor: const Duration(seconds: 10),
|
||||
pauseFor: const Duration(seconds: 3),
|
||||
listenFor: const Duration(seconds: 60),
|
||||
pauseFor: const Duration(seconds: 8),
|
||||
localeId: 'id_ID',
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stopListening() async {
|
||||
_shouldListen = false;
|
||||
_listening = false;
|
||||
await _stt.stop();
|
||||
}
|
||||
@ -42,15 +50,17 @@ class SttService {
|
||||
|
||||
void _onError(dynamic error) {
|
||||
_listening = false;
|
||||
// Auto-restart setelah error
|
||||
Future.delayed(const Duration(seconds: 1), startListening);
|
||||
if (_shouldListen) {
|
||||
Future.delayed(const Duration(seconds: 2), startListening);
|
||||
}
|
||||
}
|
||||
|
||||
void _onStatus(String status) {
|
||||
if (status == 'done' || status == 'notListening') {
|
||||
_listening = false;
|
||||
// Auto-restart agar selalu mendengarkan
|
||||
Future.delayed(const Duration(milliseconds: 500), startListening);
|
||||
if (_shouldListen) {
|
||||
Future.delayed(const Duration(seconds: 2), startListening);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,9 +4,15 @@ class TtsService {
|
||||
final FlutterTts _tts = FlutterTts();
|
||||
final List<String> _queue = [];
|
||||
bool _speaking = false;
|
||||
bool _initialized = false;
|
||||
String _lastSpoken = '';
|
||||
DateTime _lastQueuedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
Future<void> init({String language = 'id-ID', double pitch = 1.0, double rate = 0.5}) async {
|
||||
Future<void> init(
|
||||
{String language = 'id-ID',
|
||||
double pitch = 1.0,
|
||||
double rate = 0.5}) async {
|
||||
if (_initialized) return;
|
||||
await _tts.setLanguage(language);
|
||||
await _tts.setPitch(pitch);
|
||||
await _tts.setSpeechRate(rate);
|
||||
@ -15,11 +21,25 @@ class TtsService {
|
||||
_speaking = false;
|
||||
_processQueue();
|
||||
});
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Tambah ke antrian - tidak memotong yg sedang bicara
|
||||
void speak(String text) {
|
||||
if (text.isEmpty) return;
|
||||
if (!_initialized) {
|
||||
init().then((_) => speak(text));
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (text == _lastSpoken &&
|
||||
now.difference(_lastQueuedAt) < const Duration(milliseconds: 900)) {
|
||||
return;
|
||||
}
|
||||
_lastQueuedAt = now;
|
||||
if (_queue.length >= 3) {
|
||||
_queue.removeAt(0);
|
||||
}
|
||||
_queue.add(text);
|
||||
if (!_speaking) _processQueue();
|
||||
}
|
||||
@ -27,6 +47,7 @@ class TtsService {
|
||||
/// Potong TTS sekarang dan langsung ucapkan ini - untuk obstacle alert
|
||||
Future<void> speakImmediate(String text) async {
|
||||
if (text.isEmpty) return;
|
||||
await init();
|
||||
_queue.clear();
|
||||
await _tts.stop();
|
||||
_speaking = true;
|
||||
@ -43,9 +64,20 @@ class TtsService {
|
||||
String get lastSpoken => _lastSpoken;
|
||||
bool get isSpeaking => _speaking;
|
||||
|
||||
Future<void> setLanguage(String lang) async => _tts.setLanguage(lang);
|
||||
Future<void> setPitch(double pitch) async => _tts.setPitch(pitch);
|
||||
Future<void> setRate(double rate) async => _tts.setSpeechRate(rate);
|
||||
Future<void> setLanguage(String lang) async {
|
||||
await init(language: lang);
|
||||
await _tts.setLanguage(lang);
|
||||
}
|
||||
|
||||
Future<void> setPitch(double pitch) async {
|
||||
await init();
|
||||
await _tts.setPitch(pitch);
|
||||
}
|
||||
|
||||
Future<void> setRate(double rate) async {
|
||||
await init();
|
||||
await _tts.setSpeechRate(rate);
|
||||
}
|
||||
|
||||
void repeatLast() {
|
||||
if (_lastSpoken.isNotEmpty) speak(_lastSpoken);
|
||||
@ -58,4 +90,4 @@ class TtsService {
|
||||
_lastSpoken = text;
|
||||
_tts.speak(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,8 @@ class VoiceCommand {
|
||||
/// Callback yang dipanggil saat command terdeteksi
|
||||
/// Registered oleh router/screen yang relevan
|
||||
typedef CommandCallback = void Function(VoiceCommandKey key);
|
||||
typedef CommandRouter = void Function(String route);
|
||||
typedef CommandAction = void Function();
|
||||
|
||||
class VoiceCommandHandler {
|
||||
final SttService _stt;
|
||||
@ -26,9 +28,19 @@ class VoiceCommandHandler {
|
||||
|
||||
List<VoiceCommand> _commands = [];
|
||||
CommandCallback? onCommand;
|
||||
CommandRouter? _router;
|
||||
final Map<VoiceCommandKey, CommandAction> _actions = {};
|
||||
|
||||
VoiceCommandHandler(this._stt, this._tts);
|
||||
|
||||
void registerRouter(CommandRouter router) {
|
||||
_router = router;
|
||||
}
|
||||
|
||||
void registerAction(VoiceCommandKey key, CommandAction action) {
|
||||
_actions[key] = action;
|
||||
}
|
||||
|
||||
void loadCommands(List<VoiceCommand> commands) {
|
||||
_commands = commands;
|
||||
_stt.onResult = _processText;
|
||||
@ -66,9 +78,28 @@ class VoiceCommandHandler {
|
||||
}
|
||||
|
||||
void _handleCommand(VoiceCommandKey key) {
|
||||
_routeFor(key);
|
||||
_actions[key]?.call();
|
||||
onCommand?.call(key);
|
||||
// Built-in actions for TTS-only commands
|
||||
if (key == VoiceCommandKey.repeatLast) _tts.repeatLast();
|
||||
if (key == VoiceCommandKey.stopTts) _tts.stop();
|
||||
}
|
||||
|
||||
void _routeFor(VoiceCommandKey key) {
|
||||
final route = switch (key) {
|
||||
VoiceCommandKey.openWalkguide || VoiceCommandKey.startWalkguide =>
|
||||
'/user/walkguide',
|
||||
VoiceCommandKey.openNotification => '/user/notifications',
|
||||
VoiceCommandKey.openSos || VoiceCommandKey.sendSos => '/user/sos',
|
||||
VoiceCommandKey.openActivity => '/user/activity',
|
||||
VoiceCommandKey.openNavigation => '/user/navigation',
|
||||
VoiceCommandKey.openSettings => '/user/settings',
|
||||
VoiceCommandKey.callGuardian => '/call',
|
||||
_ => null,
|
||||
};
|
||||
if (route != null) {
|
||||
_router?.call(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,9 +13,9 @@ import '../storage/secure_storage.dart';
|
||||
/// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user.
|
||||
///
|
||||
/// Subscriptions yang dipakai:
|
||||
/// Guardian → /topic/location/{userId} live GPS update
|
||||
/// Guardian → /queue/sos/{guardianId} SOS alert real-time
|
||||
/// User → /queue/notif/{userId} notifikasi dari Guardian
|
||||
/// Guardian → /topic/location/{userId} live GPS update
|
||||
/// Guardian → /queue/sos/{guardianId} SOS alert real-time
|
||||
/// User → /queue/notif/{userId} notifikasi dari Guardian
|
||||
class WebSocketService {
|
||||
final SecureStorage _storage;
|
||||
|
||||
@ -26,11 +26,13 @@ class WebSocketService {
|
||||
void Function(double lat, double lng)? _onLocation;
|
||||
void Function(Map<String, dynamic> sosData)? _onSos;
|
||||
void Function(Map<String, dynamic> notifData)? _onNotif;
|
||||
void Function(Map<String, dynamic> callData)? _onCall;
|
||||
|
||||
// Subscription frames (untuk unsubscribe)
|
||||
StompUnsubscribe? _locationUnsub;
|
||||
StompUnsubscribe? _sosUnsub;
|
||||
StompUnsubscribe? _notifUnsub;
|
||||
StompUnsubscribe? _callUnsub;
|
||||
|
||||
WebSocketService(this._storage);
|
||||
|
||||
@ -88,18 +90,18 @@ class WebSocketService {
|
||||
await completer.future.timeout(const Duration(seconds: 5));
|
||||
} catch (e) {
|
||||
debugPrint('[WS] Connect timeout/error: $e');
|
||||
// Don't throw — let dashboard work without WS
|
||||
// Don't throw — let dashboard work without WS
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe ke live GPS updates dari User.
|
||||
/// Guardian panggil ini setelah connect.
|
||||
/// [userId] = ID dari ROLE_USER yang dipair.
|
||||
void subscribeLocation(String userId,
|
||||
void Function(double lat, double lng) callback) {
|
||||
void subscribeLocation(
|
||||
String userId, void Function(double lat, double lng) callback) {
|
||||
_onLocation = callback;
|
||||
if (_client == null || !_connected) {
|
||||
debugPrint('[WS] subscribeLocation skipped — not connected');
|
||||
debugPrint('[WS] subscribeLocation skipped — not connected');
|
||||
return;
|
||||
}
|
||||
_locationUnsub?.call(); // unsubscribe sebelumnya jika ada
|
||||
@ -107,8 +109,7 @@ class WebSocketService {
|
||||
destination: '/topic/location/$userId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data =
|
||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
final lat = (data['lat'] as num?)?.toDouble();
|
||||
final lng = (data['lng'] as num?)?.toDouble();
|
||||
if (lat != null && lng != null) {
|
||||
@ -135,8 +136,7 @@ class WebSocketService {
|
||||
destination: '/queue/sos/$guardianId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data =
|
||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
_onSos?.call(data);
|
||||
} catch (e) {
|
||||
debugPrint('[WS] SOS parse error: $e');
|
||||
@ -147,7 +147,7 @@ class WebSocketService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Subscribe ke notifikasi Guardian → User.
|
||||
/// Subscribe ke notifikasi Guardian → User.
|
||||
/// [userId] = ID dari ROLE_USER yang login.
|
||||
void subscribeNotification(
|
||||
void Function(Map<String, dynamic> notifData) callback) {
|
||||
@ -161,8 +161,7 @@ class WebSocketService {
|
||||
destination: '/queue/notif/$userId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data =
|
||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
_onNotif?.call(data);
|
||||
} catch (e) {
|
||||
debugPrint('[WS] Notif parse error: $e');
|
||||
@ -173,20 +172,46 @@ class WebSocketService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Subscribe ke panggilan masuk realtime. Ini melengkapi FCM agar call tetap
|
||||
/// masuk saat app foreground atau ketika FCM di app clone tidak stabil.
|
||||
void subscribeCall(void Function(Map<String, dynamic> callData) callback) {
|
||||
_onCall = callback;
|
||||
if (_client == null || !_connected) return;
|
||||
|
||||
_storage.getUserId().then((userId) {
|
||||
if (userId == null) return;
|
||||
_callUnsub?.call();
|
||||
_callUnsub = _client!.subscribe(
|
||||
destination: '/queue/call/$userId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
_onCall?.call(data);
|
||||
} catch (e) {
|
||||
debugPrint('[WS] Call parse error: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
debugPrint('[WS] Subscribed to /queue/call/$userId');
|
||||
});
|
||||
}
|
||||
|
||||
/// Disconnect dan cleanup semua subscriptions.
|
||||
Future<void> disconnect() async {
|
||||
_locationUnsub?.call();
|
||||
_sosUnsub?.call();
|
||||
_notifUnsub?.call();
|
||||
_callUnsub?.call();
|
||||
_locationUnsub = null;
|
||||
_sosUnsub = null;
|
||||
_notifUnsub = null;
|
||||
_callUnsub = null;
|
||||
_client?.deactivate();
|
||||
_client = null;
|
||||
_connected = false;
|
||||
}
|
||||
|
||||
// Legacy compat — lama pakai onMessage raw
|
||||
// Legacy compat — lama pakai onMessage raw
|
||||
void send(Object message) {
|
||||
debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.');
|
||||
}
|
||||
|
||||
@ -1,10 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
static const primary = Color(0xFF1A56DB);
|
||||
static const primaryBlue = Color(0xFF4A90D9);
|
||||
static const softBlueBg = Color(0xFFEBF4FF);
|
||||
static const softPinkBg = Color(0xFFFFF0F5);
|
||||
static const softYellowBg = Color(0xFFFFFBEB);
|
||||
static const cardWhite = Color(0xFFFFFFFF);
|
||||
static const textDark = Color(0xFF2D3748);
|
||||
static const textMuted = Color(0xFFA0AEC0);
|
||||
static const gold = Color(0xFFF6C90E);
|
||||
static const gradientBlueStart = Color(0xFF6BB8FF);
|
||||
static const gradientBlueEnd = Color(0xFF4A90D9);
|
||||
static const gradientPinkStart = Color(0xFFFFB3C6);
|
||||
static const gradientPinkEnd = Color(0xFFFF6B9D);
|
||||
|
||||
static const primary = primaryBlue;
|
||||
static const primaryDark = Color(0xFF256FB8);
|
||||
static const accent = Color(0xFFFF6B9D);
|
||||
static const warning = Color(0xFFD97706);
|
||||
static const danger = Color(0xFFDC2626);
|
||||
static const success = Color(0xFF16A34A);
|
||||
static const surface = Color(0xFFF8FAFC);
|
||||
static const text = Color(0xFF0F172A);
|
||||
static const muted = Color(0xFF64748B);
|
||||
static const success = Color(0xFF059669);
|
||||
static const surface = softBlueBg;
|
||||
static const surfaceRaised = cardWhite;
|
||||
static const text = textDark;
|
||||
static const muted = textMuted;
|
||||
static const border = Color(0xFFE2E8F0);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,9 @@ import '../../app/injection_container.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_decorations.dart';
|
||||
import '../../core/theme/app_text_styles.dart';
|
||||
import '../../shared/widgets/animations/animations.dart';
|
||||
|
||||
Dio get _api => sl<ApiClient>().dio;
|
||||
|
||||
@ -103,91 +106,114 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.softBlueBg, Colors.white],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: FadeSlideWrapper(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Activity Log',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.w800),
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Activity Log',
|
||||
style: AppTextStyles.heading,
|
||||
),
|
||||
Text(
|
||||
'${_items.length} aktivitas tercatat',
|
||||
style: const TextStyle(color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'${_items.length} aktivitas tercatat',
|
||||
style: const TextStyle(color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Filter chips
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _filters.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, i) {
|
||||
final f = _filters[i];
|
||||
final selected = _selectedFilter == f;
|
||||
return FilterChip(
|
||||
label: Text(f),
|
||||
selected: selected,
|
||||
onSelected: (_) {
|
||||
setState(() => _applyFilter(f));
|
||||
},
|
||||
selectedColor:
|
||||
AppColors.primary.withValues(alpha: 0.15),
|
||||
backgroundColor: AppColors.cardWhite,
|
||||
side: BorderSide(
|
||||
color: selected
|
||||
? AppColors.primary.withValues(alpha: 0.4)
|
||||
: AppColors.border,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
checkmarkColor: AppColors.primary,
|
||||
labelStyle: TextStyle(
|
||||
color: selected ? AppColors.primary : AppColors.muted,
|
||||
fontWeight:
|
||||
selected ? FontWeight.w700 : FontWeight.normal,
|
||||
fontSize: 12,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Body
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _ErrorPanel(message: _error!, onRetry: _load)
|
||||
: _filtered.isEmpty
|
||||
? _EmptyPanel(filter: _selectedFilter)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
children: [
|
||||
StaggerWrapper(
|
||||
children: [
|
||||
for (final item in _filtered)
|
||||
_LogCard(item: item),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Filter chips
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _filters.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, i) {
|
||||
final f = _filters[i];
|
||||
final selected = _selectedFilter == f;
|
||||
return FilterChip(
|
||||
label: Text(f),
|
||||
selected: selected,
|
||||
onSelected: (_) {
|
||||
setState(() => _applyFilter(f));
|
||||
},
|
||||
selectedColor: AppColors.primary.withValues(alpha: 0.15),
|
||||
checkmarkColor: AppColors.primary,
|
||||
labelStyle: TextStyle(
|
||||
color: selected ? AppColors.primary : AppColors.muted,
|
||||
fontWeight:
|
||||
selected ? FontWeight.w700 : FontWeight.normal,
|
||||
fontSize: 12,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Body
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _ErrorPanel(message: _error!, onRetry: _load)
|
||||
: _filtered.isEmpty
|
||||
? _EmptyPanel(filter: _selectedFilter)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
itemCount: _filtered.length,
|
||||
itemBuilder: (ctx, i) =>
|
||||
_LogCard(item: _filtered[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -228,71 +254,76 @@ class _LogCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final meta = _logMeta(item.logType);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Timeline dot + line
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: meta.color.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardWhite,
|
||||
borderRadius: AppDecorations.cardRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppDecorations.cardShadow,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Timeline dot + line
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: meta.color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Icon(meta.icon, color: meta.color, size: 18),
|
||||
),
|
||||
child: Icon(meta.icon, color: meta.color, size: 18),
|
||||
),
|
||||
Container(
|
||||
width: 1.5,
|
||||
height: 20,
|
||||
color: const Color(0xFFE2E8F0),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
meta.label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: meta.color,
|
||||
fontSize: 13,
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
meta.label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: meta.color,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (item.description != null && item.description!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
item.description!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.text),
|
||||
),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (item.description != null &&
|
||||
item.description!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
item.description!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppColors.text),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -394,21 +425,29 @@ class _ErrorPanel extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off, size: 48, color: AppColors.muted),
|
||||
const SizedBox(height: 12),
|
||||
Text(message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.muted)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Coba lagi'),
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.cardWhite,
|
||||
borderRadius: AppDecorations.cardRadius,
|
||||
boxShadow: AppDecorations.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.wifi_off, size: 48, color: AppColors.muted),
|
||||
const SizedBox(height: 12),
|
||||
Text(message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.muted)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Coba lagi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -421,21 +460,29 @@ class _EmptyPanel extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.history, size: 64, color: AppColors.muted),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
filter == 'ALL'
|
||||
? 'Belum ada aktivitas'
|
||||
: 'Tidak ada aktivitas "$filter"',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.cardWhite,
|
||||
borderRadius: AppDecorations.cardRadius,
|
||||
boxShadow: AppDecorations.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.history, size: 64, color: AppColors.muted),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
filter == 'ALL'
|
||||
? 'Belum ada aktivitas'
|
||||
: 'Tidak ada aktivitas "$filter"',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
Activity log application layer.
|
||||
|
||||
This layer is reserved for Cubit/BLoC orchestration between the activity log UI and domain contracts.
|
||||
@ -0,0 +1,3 @@
|
||||
Activity log data layer.
|
||||
|
||||
This layer is reserved for remote/local data sources and repository implementations.
|
||||
@ -0,0 +1,3 @@
|
||||
Activity log domain layer.
|
||||
|
||||
This layer is reserved for entities, repository contracts, and use cases.
|
||||
@ -9,8 +9,11 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/ai/detection_export.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_decorations.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import '../../core/utils/operation_guard.dart';
|
||||
import '../../shared/widgets/animations/animations.dart';
|
||||
import '../../shared/widgets/feature_page.dart';
|
||||
|
||||
class AiBenchmarkScreen extends StatefulWidget {
|
||||
@ -116,18 +119,22 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
||||
CameraController? controller;
|
||||
await guarded<void>(
|
||||
() async {
|
||||
final cameras =
|
||||
await availableCameras().timeout(const Duration(seconds: 3));
|
||||
if (cameras.isNotEmpty) {
|
||||
final activeController = CameraController(
|
||||
cameras.first,
|
||||
ResolutionPreset.low,
|
||||
enableAudio: false,
|
||||
);
|
||||
controller = activeController;
|
||||
await activeController.initialize().timeout(const Duration(seconds: 5));
|
||||
await activeController.takePicture().timeout(const Duration(seconds: 5));
|
||||
}
|
||||
final cameras =
|
||||
await availableCameras().timeout(const Duration(seconds: 3));
|
||||
if (cameras.isNotEmpty) {
|
||||
final activeController = CameraController(
|
||||
cameras.first,
|
||||
ResolutionPreset.low,
|
||||
enableAudio: false,
|
||||
);
|
||||
controller = activeController;
|
||||
await activeController
|
||||
.initialize()
|
||||
.timeout(const Duration(seconds: 5));
|
||||
await activeController
|
||||
.takePicture()
|
||||
.timeout(const Duration(seconds: 5));
|
||||
}
|
||||
},
|
||||
onError: (_) {},
|
||||
);
|
||||
@ -198,7 +205,11 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
||||
label: const Text('Clear log'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
for (final run in _runs) _BenchmarkCard(run: run),
|
||||
StaggerWrapper(
|
||||
children: [
|
||||
for (final run in _runs) _BenchmarkCard(run: run),
|
||||
],
|
||||
),
|
||||
if (_runs.isEmpty)
|
||||
const FeatureEmptyPanel(
|
||||
icon: Icons.speed,
|
||||
@ -224,9 +235,10 @@ class _BenchmarkCard extends StatelessWidget {
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
color: AppColors.cardWhite,
|
||||
borderRadius: AppDecorations.cardRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppDecorations.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -262,7 +274,8 @@ class _StatusBox extends StatelessWidget {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: AppDecorations.cardRadius,
|
||||
boxShadow: AppDecorations.cardShadow,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@ -279,17 +292,17 @@ class _StatusBox extends StatelessWidget {
|
||||
|
||||
Future<List<String>> _discoverTfliteModels() async {
|
||||
return await guarded<List<String>>(
|
||||
() async {
|
||||
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
|
||||
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
|
||||
final models = manifest.keys
|
||||
.where((key) =>
|
||||
key.startsWith('assets/models/') && key.endsWith('.tflite'))
|
||||
.toList()
|
||||
..sort();
|
||||
return models;
|
||||
},
|
||||
) ??
|
||||
() async {
|
||||
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
|
||||
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
|
||||
final models = manifest.keys
|
||||
.where((key) =>
|
||||
key.startsWith('assets/models/') && key.endsWith('.tflite'))
|
||||
.toList()
|
||||
..sort();
|
||||
return models;
|
||||
},
|
||||
) ??
|
||||
const [];
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
AI benchmark application layer.
|
||||
|
||||
This layer is reserved for benchmark Cubit/BLoC orchestration.
|
||||
@ -0,0 +1,3 @@
|
||||
AI benchmark data layer.
|
||||
|
||||
This layer is reserved for benchmark result persistence and export adapters.
|
||||
@ -0,0 +1,3 @@
|
||||
AI benchmark domain layer.
|
||||
|
||||
This layer is reserved for benchmark entities and use cases.
|
||||
@ -0,0 +1,3 @@
|
||||
Auth application layer.
|
||||
|
||||
This layer is reserved for auth Cubit/BLoC orchestration between auth UI and auth domain contracts.
|
||||
@ -8,14 +8,20 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../app/app_cubit.dart';
|
||||
import '../../app/router.dart';
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/services/fcm_service.dart';
|
||||
import '../../core/services/incoming_call_polling_service.dart';
|
||||
import '../../core/services/offline_queue_service.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import '../../core/services/websocket_service.dart';
|
||||
import '../../core/storage/secure_storage.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_decorations.dart';
|
||||
import '../../core/theme/app_text_styles.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LoginScreen
|
||||
@ -76,7 +82,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
},
|
||||
onError: (message) => _snack(context, message),
|
||||
fallback: 'Login gagal. Periksa email dan password kamu.',
|
||||
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
|
||||
connectionHint:
|
||||
'Tidak bisa ke server. Cek URL backend di Connect Server dan pastikan server aktif.',
|
||||
);
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
@ -147,125 +154,152 @@ class _AuthFrame extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFEAF4FF),
|
||||
body: Stack(
|
||||
children: [
|
||||
const Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
|
||||
),
|
||||
backgroundColor: AppColors.softBlueBg,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final compact =
|
||||
constraints.maxWidth < 480 || constraints.maxHeight < 720;
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.softBlueBg,
|
||||
Colors.white,
|
||||
AppColors.softPinkBg,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -90,
|
||||
right: -60,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.85, end: 1),
|
||||
duration: const Duration(milliseconds: 900),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, value, child) => Transform.scale(
|
||||
scale: value,
|
||||
child: child,
|
||||
),
|
||||
child: Container(
|
||||
width: 260,
|
||||
height: 260,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 430),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 18, end: 0),
|
||||
duration: const Duration(milliseconds: 520),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, offset, child) => Opacity(
|
||||
opacity: (1 - offset / 18).clamp(0.0, 1.0),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, offset),
|
||||
child: child,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
compact ? 14 : 24,
|
||||
compact ? 12 : 24,
|
||||
compact ? 14 : 24,
|
||||
20 + MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: RepaintBoundary(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.96),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.8)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 20),
|
||||
),
|
||||
],
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: compact ? 380 : 430),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 18, end: 0),
|
||||
duration: const Duration(milliseconds: 520),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, offset, child) => Opacity(
|
||||
opacity: (1 - offset / 18).clamp(0.0, 1.0),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, offset),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
child: RepaintBoundary(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardWhite,
|
||||
borderRadius:
|
||||
BorderRadius.circular(compact ? 22 : 28),
|
||||
boxShadow: AppDecorations.cardShadow,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
compact ? 18 : 24,
|
||||
compact ? 18 : 26,
|
||||
compact ? 18 : 24,
|
||||
compact ? 18 : 24,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1D4ED8),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(Icons.navigation_rounded,
|
||||
color: Colors.white, size: 30),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: compact ? 44 : 56,
|
||||
height: compact ? 44 : 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppDecorations.blueGradient,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x334A90D9),
|
||||
blurRadius: 18,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(Icons.navigation_rounded,
|
||||
color: Colors.white,
|
||||
size: compact ? 26 : 30),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'WalkGuide',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'WalkGuide',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFF0F172A),
|
||||
SizedBox(height: compact ? 14 : 16),
|
||||
if (!compact)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.softBlueBg,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.shield_outlined,
|
||||
size: 14,
|
||||
color: AppColors.primaryBlue),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Secure Assistive Navigation',
|
||||
style: TextStyle(
|
||||
color: AppColors.primaryBlue,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!compact) const SizedBox(height: 18),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyles.heading.copyWith(
|
||||
fontSize: compact ? 26 : null,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? 18 : 26),
|
||||
child,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: const Color(0xFF0F172A),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -273,8 +307,8 @@ class _AuthFrame extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -311,9 +345,16 @@ Future<void> _saveAuthAndRoute(
|
||||
|
||||
void _startPostLoginServices(String serverUrl) {
|
||||
Future.microtask(() async {
|
||||
await sl<WebSocketService>()
|
||||
.connect(serverUrl)
|
||||
.timeout(const Duration(seconds: 2));
|
||||
sl<IncomingCallPollingService>().start();
|
||||
await sl<FcmService>().init().timeout(const Duration(seconds: 4));
|
||||
final ws = sl<WebSocketService>();
|
||||
await ws.connect(serverUrl).timeout(const Duration(seconds: 2));
|
||||
ws.subscribeCall((data) {
|
||||
final type = data['type']?.toString();
|
||||
if (type == 'INCOMING_CALL') {
|
||||
appRouter.go('/incoming-call', extra: data);
|
||||
}
|
||||
});
|
||||
await sl<OfflineQueueService>()
|
||||
.syncPending(sl<ApiClient>())
|
||||
.timeout(const Duration(seconds: 3));
|
||||
|
||||
@ -7,6 +7,10 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_decorations.dart';
|
||||
import '../../core/theme/app_text_styles.dart';
|
||||
import '../../shared/widgets/animations/animations.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RegisterScreen
|
||||
@ -69,7 +73,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
},
|
||||
onError: (message) => _snack(context, message),
|
||||
fallback: 'Registrasi gagal. Periksa data akun kamu.',
|
||||
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
|
||||
connectionHint:
|
||||
'Tidak bisa ke server. Cek URL backend di Connect Server dan pastikan server aktif.',
|
||||
);
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
@ -128,7 +133,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _role == 'USER'
|
||||
? const Color(0xFFEFF6FF)
|
||||
? AppColors.softBlueBg
|
||||
: const Color(0xFFF0FDF4),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
@ -234,18 +239,19 @@ class _RoleCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
return BounceTap(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFFEFF6FF) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: selected ? AppColors.softBlueBg : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: selected ? const Color(0xFF1A56DB) : const Color(0xFFE2E8F0),
|
||||
color: selected ? AppColors.primaryBlue : AppColors.border,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
boxShadow: selected ? AppDecorations.cardShadow : null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@ -253,10 +259,9 @@ class _RoleCard extends StatelessWidget {
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? const Color(0xFF1A56DB)
|
||||
: const Color(0xFFF1F5F9),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color:
|
||||
selected ? AppColors.primaryBlue : const Color(0xFFF1F5F9),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Icon(icon,
|
||||
color: selected ? Colors.white : const Color(0xFF64748B)),
|
||||
@ -267,16 +272,16 @@ class _RoleCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800, fontSize: 16)),
|
||||
style: AppTextStyles.subheading.copyWith(fontSize: 16)),
|
||||
Text(subtitle,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B), fontSize: 13)),
|
||||
color: AppColors.muted, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
const Icon(Icons.check_circle_rounded, color: Color(0xFF1A56DB)),
|
||||
const Icon(Icons.check_circle_rounded,
|
||||
color: AppColors.primaryBlue),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -298,125 +303,125 @@ class _AuthFrame extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFEAF4FF),
|
||||
body: Stack(
|
||||
children: [
|
||||
const Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
|
||||
),
|
||||
backgroundColor: AppColors.softBlueBg,
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final compact =
|
||||
constraints.maxWidth < 480 || constraints.maxHeight < 720;
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.softBlueBg,
|
||||
Colors.white,
|
||||
AppColors.softPinkBg,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -90,
|
||||
right: -60,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.85, end: 1),
|
||||
duration: const Duration(milliseconds: 900),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, value, child) => Transform.scale(
|
||||
scale: value,
|
||||
child: child,
|
||||
),
|
||||
child: Container(
|
||||
width: 260,
|
||||
height: 260,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 430),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 18, end: 0),
|
||||
duration: const Duration(milliseconds: 520),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, offset, child) => Opacity(
|
||||
opacity: (1 - offset / 18).clamp(0.0, 1.0),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, offset),
|
||||
child: child,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
compact ? 14 : 24,
|
||||
compact ? 12 : 24,
|
||||
compact ? 14 : 24,
|
||||
20 + MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: RepaintBoundary(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.96),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.8)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 20),
|
||||
),
|
||||
],
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: compact ? 380 : 430),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 18, end: 0),
|
||||
duration: const Duration(milliseconds: 520),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, offset, child) => Opacity(
|
||||
opacity: (1 - offset / 18).clamp(0.0, 1.0),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, offset),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
child: RepaintBoundary(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardWhite,
|
||||
borderRadius:
|
||||
BorderRadius.circular(compact ? 22 : 28),
|
||||
boxShadow: AppDecorations.cardShadow,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
compact ? 18 : 24,
|
||||
compact ? 18 : 26,
|
||||
compact ? 18 : 24,
|
||||
compact ? 18 : 24,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1D4ED8),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(Icons.navigation_rounded,
|
||||
color: Colors.white, size: 30),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'WalkGuide',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFF0F172A),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: compact ? 44 : 56,
|
||||
height: compact ? 44 : 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppDecorations.blueGradient,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x334A90D9),
|
||||
blurRadius: 18,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(Icons.navigation_rounded,
|
||||
color: Colors.white,
|
||||
size: compact ? 26 : 30),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'WalkGuide',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textDark,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: compact ? 14 : 22),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyles.heading.copyWith(
|
||||
fontSize: compact ? 26 : null,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
SizedBox(height: compact ? 18 : 26),
|
||||
child,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: const Color(0xFF0F172A),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -424,8 +429,8 @@ class _AuthFrame extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,9 +3,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../app/router.dart';
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/services/fcm_service.dart';
|
||||
import '../../core/services/incoming_call_polling_service.dart';
|
||||
import '../../core/services/offline_queue_service.dart';
|
||||
import '../../core/services/websocket_service.dart';
|
||||
import '../../core/storage/secure_storage.dart';
|
||||
import '../../shared/widgets/walkguide_loading_screen.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SplashScreen
|
||||
@ -32,110 +40,101 @@ class SplashScreen extends StatefulWidget {
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _animCtrl;
|
||||
late final Animation<double> _fadeAnim;
|
||||
late final AnimationController _screenCtrl;
|
||||
late final Animation<double> _screenFade;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animCtrl = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 700));
|
||||
_fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeIn);
|
||||
_animCtrl.forward();
|
||||
_screenCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
value: 1,
|
||||
);
|
||||
_screenFade = CurvedAnimation(
|
||||
parent: _screenCtrl,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
_route();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animCtrl.dispose();
|
||||
_screenCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _route() async {
|
||||
final routed = await runFriendlyAction(
|
||||
() async {
|
||||
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
// Animasi logo selalu tampil minimal 900ms agar tidak langsung flash.
|
||||
await Future.delayed(const Duration(milliseconds: 900));
|
||||
|
||||
final storage = sl<SecureStorage>();
|
||||
final token =
|
||||
await storage.getAccessToken().timeout(const Duration(seconds: 3));
|
||||
final role =
|
||||
await storage.getUserRole().timeout(const Duration(seconds: 3));
|
||||
final token = await storage.getAccessToken().timeout(
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
final role = await storage.getUserRole().timeout(
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (token == null || role == null) {
|
||||
context.go('/login');
|
||||
await _fadeOutThenGo('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
final serverUrl = await AppConstants.getServerUrl();
|
||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||
_startAutoLoginServices(serverUrl);
|
||||
} else {
|
||||
sl<IncomingCallPollingService>().start();
|
||||
}
|
||||
// Auto-login: arahkan ke home sesuai role.
|
||||
context.go(role == 'ROLE_GUARDIAN'
|
||||
? '/guardian/dashboard'
|
||||
: '/user/walkguide');
|
||||
await _fadeOutThenGo(
|
||||
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide',
|
||||
);
|
||||
},
|
||||
onError: (_) {},
|
||||
fallback: 'Sesi belum bisa dipulihkan.',
|
||||
);
|
||||
if (!routed && mounted) context.go('/login');
|
||||
if (!routed && mounted) await _fadeOutThenGo('/login');
|
||||
}
|
||||
|
||||
Future<void> _fadeOutThenGo(String route) async {
|
||||
if (!mounted) return;
|
||||
await _screenCtrl.reverse();
|
||||
if (mounted) context.go(route);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF1A56DB),
|
||||
body: Center(
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnim,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo / icon
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.navigation_rounded,
|
||||
color: Colors.white,
|
||||
size: 60,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'WalkGuide',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'AI-powered navigation for everyone',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
const SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
return FadeTransition(
|
||||
opacity: _screenFade,
|
||||
child: const WalkGuideLoadingScreen(
|
||||
subtitle: 'Restoring your session',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _startAutoLoginServices(String serverUrl) {
|
||||
Future.microtask(() async {
|
||||
sl<IncomingCallPollingService>().start();
|
||||
await sl<ApiClient>().init(serverUrl);
|
||||
await sl<FcmService>().init().timeout(const Duration(seconds: 4));
|
||||
final ws = sl<WebSocketService>();
|
||||
await ws.connect(serverUrl).timeout(const Duration(seconds: 2));
|
||||
ws.subscribeCall((data) {
|
||||
if (data['type']?.toString() == 'INCOMING_CALL') {
|
||||
appRouter.go('/incoming-call', extra: data);
|
||||
}
|
||||
});
|
||||
await sl<OfflineQueueService>()
|
||||
.syncPending(sl<ApiClient>())
|
||||
.timeout(const Duration(seconds: 3));
|
||||
}).catchError((Object e) {
|
||||
debugPrint('Auto-login services skipped: $e');
|
||||
});
|
||||
}
|
||||
|
||||
@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,3 @@
|
||||
Call data layer.
|
||||
|
||||
This layer is reserved for call remote data sources and repository implementations over `/shared/call/**`.
|
||||
@ -0,0 +1,3 @@
|
||||
Call domain layer.
|
||||
|
||||
This layer is reserved for call session entities, repository contracts, and call use cases.
|
||||
@ -0,0 +1,3 @@
|
||||
Guardian dashboard application layer.
|
||||
|
||||
This layer is reserved for dashboard, map, SOS, notification, AI config, shortcut, and geofence Cubits.
|
||||
@ -0,0 +1,3 @@
|
||||
Guardian dashboard data layer.
|
||||
|
||||
This layer is reserved for `/guardian/**` data sources and repository implementations.
|
||||
@ -0,0 +1,3 @@
|
||||
Guardian dashboard domain layer.
|
||||
|
||||
This layer is reserved for Guardian dashboard entities, repository contracts, and use cases.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,9 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_decorations.dart';
|
||||
import '../../shared/widgets/animations/animations.dart';
|
||||
import '../../shared/widgets/feature_page.dart';
|
||||
|
||||
class GuardianMapScreen extends StatefulWidget {
|
||||
@ -106,8 +109,9 @@ class _GuardianMapCard extends StatelessWidget {
|
||||
final center = _pointFrom(location) ??
|
||||
(points.isNotEmpty ? points.first : null) ??
|
||||
const LatLng(-7.2575, 112.7521);
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
return Container(
|
||||
decoration: AppDecorations.card,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: FlutterMap(
|
||||
options: MapOptions(initialCenter: center, initialZoom: 16),
|
||||
children: [
|
||||
@ -121,7 +125,7 @@ class _GuardianMapCard extends StatelessWidget {
|
||||
Polyline(
|
||||
points: points,
|
||||
strokeWidth: 4,
|
||||
color: const Color(0xFF2563EB),
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -171,10 +175,18 @@ class _TimelineList extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: segments.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, index) => _TimelineCard(segment: segments[index]),
|
||||
return ListView(
|
||||
children: [
|
||||
StaggerWrapper(
|
||||
children: [
|
||||
for (final segment in segments)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _TimelineCard(segment: segment),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -189,9 +201,10 @@ class _TimelineCard extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
color: AppColors.cardWhite,
|
||||
borderRadius: AppDecorations.cardRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppDecorations.cardShadow,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@ -199,10 +212,10 @@ class _TimelineCard extends StatelessWidget {
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: AppColors.softBlueBg,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: Icon(segment.icon, color: const Color(0xFF1D4ED8)),
|
||||
child: Icon(segment.icon, color: AppColors.primaryBlue),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
|
||||
@ -8,6 +8,9 @@ import 'package:record/record.dart';
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_decorations.dart';
|
||||
import '../../shared/widgets/animations/animations.dart';
|
||||
import '../../shared/widgets/feature_page.dart';
|
||||
|
||||
class GuardianSendNotifScreen extends StatefulWidget {
|
||||
@ -132,132 +135,130 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
||||
subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
|
||||
child: ListView(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF1E293B).withValues(alpha: 0.06),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
icon: Icon(Icons.message_outlined),
|
||||
label: Text('Text'),
|
||||
FadeSlideWrapper(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardWhite,
|
||||
borderRadius: AppDecorations.cardRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppDecorations.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
icon: Icon(Icons.message_outlined),
|
||||
label: Text('Text'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
icon: Icon(Icons.mic_none_outlined),
|
||||
label: Text('Voice'),
|
||||
),
|
||||
],
|
||||
selected: {_voiceMode},
|
||||
onSelectionChanged: _loading || _recording
|
||||
? null
|
||||
: (value) => setState(() => _voiceMode = value.first),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _message,
|
||||
minLines: _voiceMode ? 2 : 5,
|
||||
maxLines: _voiceMode ? 3 : 8,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Message',
|
||||
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
|
||||
prefixIcon: Icon(Icons.message_outlined),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
icon: Icon(Icons.mic_none_outlined),
|
||||
label: Text('Voice'),
|
||||
),
|
||||
if (_voiceMode) ...[
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: AppDecorations.cardRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: _recording
|
||||
? const Color(0xFFFEE2E2)
|
||||
: const Color(0xFFEFF6FF),
|
||||
child: Icon(
|
||||
_recording ? Icons.graphic_eq : Icons.mic,
|
||||
color: _recording
|
||||
? const Color(0xFFDC2626)
|
||||
: const Color(0xFF2563EB),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_recording
|
||||
? 'Recording... tap stop when done'
|
||||
: _voicePath == null
|
||||
? 'No voice note recorded'
|
||||
: 'Voice note ready',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800),
|
||||
),
|
||||
Text(
|
||||
_recording
|
||||
? 'Speak clearly near the microphone'
|
||||
: _voicePath == null
|
||||
? 'Record a short message for User'
|
||||
: '${_voiceDuration}s audio attached',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _loading ? null : _toggleRecording,
|
||||
icon: Icon(_recording
|
||||
? Icons.stop
|
||||
: Icons.fiber_manual_record),
|
||||
label: Text(_recording ? 'Stop' : 'Record'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _recording
|
||||
? const Color(0xFFDC2626)
|
||||
: const Color(0xFF2563EB),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
selected: {_voiceMode},
|
||||
onSelectionChanged: _loading || _recording
|
||||
? null
|
||||
: (value) => setState(() => _voiceMode = value.first),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _message,
|
||||
minLines: _voiceMode ? 2 : 5,
|
||||
maxLines: _voiceMode ? 3 : 8,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Message',
|
||||
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
|
||||
prefixIcon: Icon(Icons.message_outlined),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
if (_voiceMode) ...[
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: _recording
|
||||
? const Color(0xFFFEE2E2)
|
||||
: const Color(0xFFEFF6FF),
|
||||
child: Icon(
|
||||
_recording ? Icons.graphic_eq : Icons.mic,
|
||||
color: _recording
|
||||
? const Color(0xFFDC2626)
|
||||
: const Color(0xFF2563EB),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_recording
|
||||
? 'Recording... tap stop when done'
|
||||
: _voicePath == null
|
||||
? 'No voice note recorded'
|
||||
: 'Voice note ready',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800),
|
||||
),
|
||||
Text(
|
||||
_recording
|
||||
? 'Speak clearly near the microphone'
|
||||
: _voicePath == null
|
||||
? 'Record a short message for User'
|
||||
: '${_voiceDuration}s audio attached',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _loading ? null : _toggleRecording,
|
||||
icon: Icon(_recording ? Icons.stop : Icons.fiber_manual_record),
|
||||
label: Text(_recording ? 'Stop' : 'Record'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _recording
|
||||
? const Color(0xFFDC2626)
|
||||
: const Color(0xFF2563EB),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _loading ? null : _send,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
label: Text(_loading
|
||||
? 'Sending...'
|
||||
: _voiceMode
|
||||
? 'Send Voice Message'
|
||||
: 'Send Message'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: _loading ? null : _send,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
label: Text(_loading
|
||||
? 'Sending...'
|
||||
: _voiceMode
|
||||
? 'Send Voice Message'
|
||||
: 'Send Message'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -9,6 +9,9 @@ import '../../app/injection_container.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_decorations.dart';
|
||||
import '../../shared/widgets/animations/animations.dart';
|
||||
import '../../core/storage/secure_storage.dart';
|
||||
import '../../shared/widgets/feature_page.dart';
|
||||
|
||||
@ -49,23 +52,27 @@ class GuardianSettingsScreen extends StatelessWidget {
|
||||
subtitle: 'Account, pairing, AI tools, and server',
|
||||
child: ListView(
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.link,
|
||||
title: 'Pair User',
|
||||
subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
|
||||
onTap: () => context.go('/guardian/pairing'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.speed,
|
||||
title: 'AI Benchmark',
|
||||
subtitle: 'Catat capture, inference, notification, dan TTS.',
|
||||
onTap: () => context.go('/guardian/benchmark'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.tune,
|
||||
title: 'AI Config',
|
||||
subtitle: 'Atur threshold deteksi dan label yang aktif.',
|
||||
onTap: () => context.go('/guardian/ai-config'),
|
||||
StaggerWrapper(
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.link,
|
||||
title: 'Pair User',
|
||||
subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
|
||||
onTap: () => context.go('/guardian/pairing'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.speed,
|
||||
title: 'AI Benchmark',
|
||||
subtitle: 'Catat capture, inference, notification, dan TTS.',
|
||||
onTap: () => context.go('/guardian/benchmark'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.tune,
|
||||
title: 'AI Config',
|
||||
subtitle: 'Atur threshold deteksi dan label yang aktif.',
|
||||
onTap: () => context.go('/guardian/ai-config'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
OutlinedButton.icon(
|
||||
@ -103,19 +110,28 @@ class _SettingsTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: const Color(0xFF1D4ED8)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
return BounceTap(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardWhite,
|
||||
borderRadius: AppDecorations.cardRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppDecorations.cardShadow,
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: AppDecorations.iconCircle(),
|
||||
child: Icon(icon, color: AppColors.primaryBlue),
|
||||
),
|
||||
title:
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -158,8 +158,10 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: Text(_labelFromKey(item['commandKey']?.toString() ?? '') ??
|
||||
'Voice Command'),
|
||||
title: Text(
|
||||
_labelFromKey(item['commandKey']?.toString() ?? '') ??
|
||||
'Voice Command',
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@ -180,11 +182,13 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel')),
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Save')),
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -209,8 +213,9 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: Text(_labelFromKey(item['shortcutKey']?.toString() ?? '') ??
|
||||
'Shortcut'),
|
||||
title: Text(
|
||||
_labelFromKey(item['shortcutKey']?.toString() ?? '') ?? 'Shortcut',
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@ -230,6 +235,31 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
||||
prefixIcon: Icon(Icons.numbers_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.volume_up_outlined, size: 18),
|
||||
label: const Text('Volume Up'),
|
||||
onPressed: () {
|
||||
buttonName.text = 'Volume Up';
|
||||
buttonCode.text = '24';
|
||||
setDialogState(() {});
|
||||
},
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.volume_down_outlined, size: 18),
|
||||
label: const Text('Volume Down'),
|
||||
onPressed: () {
|
||||
buttonName.text = 'Volume Down';
|
||||
buttonCode.text = '25';
|
||||
setDialogState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: enabled,
|
||||
@ -240,11 +270,13 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel')),
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Save')),
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -273,8 +305,9 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
||||
},
|
||||
onError: (message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
},
|
||||
fallback: 'Konfigurasi belum bisa disimpan.',
|
||||
);
|
||||
@ -301,18 +334,15 @@ class _EndpointCard extends StatelessWidget {
|
||||
'',
|
||||
) ??
|
||||
'Item #${item['id'] ?? '-'}';
|
||||
final subtitle = _firstText(
|
||||
item,
|
||||
[
|
||||
'triggerPhrase',
|
||||
'buttonName',
|
||||
'description',
|
||||
'action',
|
||||
'shortcut',
|
||||
'status',
|
||||
'createdAt'
|
||||
],
|
||||
) ??
|
||||
final subtitle = _firstText(item, [
|
||||
'triggerPhrase',
|
||||
'buttonName',
|
||||
'description',
|
||||
'action',
|
||||
'shortcut',
|
||||
'status',
|
||||
'createdAt',
|
||||
]) ??
|
||||
'Data aktif';
|
||||
final enabled = item['enabled'] != false;
|
||||
return Container(
|
||||
@ -338,11 +368,15 @@ class _EndpointCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(subtitle,
|
||||
style: const TextStyle(color: Color(0xFF64748B))),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(color: Color(0xFF64748B)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
@ -415,7 +449,6 @@ String? _labelFromKey(String value) {
|
||||
return value
|
||||
.split('_')
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map((part) =>
|
||||
part[0].toUpperCase() + part.substring(1).toLowerCase())
|
||||
.map((part) => part[0].toUpperCase() + part.substring(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
Home application layer.
|
||||
|
||||
This layer is reserved for role-specific home state orchestration.
|
||||
@ -0,0 +1,3 @@
|
||||
Home data layer.
|
||||
|
||||
This layer is reserved for home/dashboard data adapters.
|
||||
@ -0,0 +1,3 @@
|
||||
Home domain layer.
|
||||
|
||||
This layer is reserved for home/dashboard domain entities and use cases.
|
||||
@ -1,5 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_text_styles.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
final String role;
|
||||
|
||||
@ -7,14 +10,26 @@ class HomeScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Dashboard Walk Guide')),
|
||||
body: Center(
|
||||
child: Text(
|
||||
role == 'ROLE_ADMIN' ? 'Selamat Datang Admin!' : 'Mode Walk Guide Siap!',
|
||||
style: const TextStyle(fontSize: 24),
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.softBlueBg, Colors.white],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
role == 'ROLE_ADMIN'
|
||||
? 'Selamat Datang Admin!'
|
||||
: 'Mode Walk Guide Siap!',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTextStyles.heading,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,6 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:camera/camera.dart';
|
||||
import '../../../core/secure_storage.dart';
|
||||
import '../../auth/presentation/login_screen.dart';
|
||||
import '../../../../main.dart';
|
||||
|
||||
class UserDashboardScreen extends StatefulWidget {
|
||||
const UserDashboardScreen({super.key});
|
||||
@ -12,7 +11,8 @@ class UserDashboardScreen extends StatefulWidget {
|
||||
State<UserDashboardScreen> createState() => _UserDashboardScreenState();
|
||||
}
|
||||
|
||||
class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerProviderStateMixin {
|
||||
class _UserDashboardScreenState extends State<UserDashboardScreen>
|
||||
with TickerProviderStateMixin {
|
||||
CameraController? _camCtrl;
|
||||
late AnimationController _radarCtrl;
|
||||
late Animation<double> _radarAnim;
|
||||
@ -31,8 +31,10 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
|
||||
}
|
||||
|
||||
Future<void> _initCamera() async {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) return;
|
||||
_camCtrl = CameraController(cameras[0], ResolutionPreset.high, enableAudio: false);
|
||||
_camCtrl = CameraController(cameras[0], ResolutionPreset.medium,
|
||||
enableAudio: false);
|
||||
await _camCtrl!.initialize();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
@ -85,7 +87,8 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
const Color(0xFF10B981).withValues(alpha: 0.05 + _radarCtrl.value * 0.08),
|
||||
const Color(0xFF10B981)
|
||||
.withValues(alpha: 0.05 + _radarCtrl.value * 0.08),
|
||||
],
|
||||
stops: const [0.5, 1.0],
|
||||
radius: 1.4,
|
||||
@ -127,7 +130,8 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.54),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@ -158,7 +162,8 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: _logout,
|
||||
icon: const Icon(Icons.power_settings_new, color: Colors.white, size: 26),
|
||||
icon: const Icon(Icons.power_settings_new,
|
||||
color: Colors.white, size: 26),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.redAccent.withValues(alpha: 0.8),
|
||||
),
|
||||
@ -204,15 +209,19 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
|
||||
color: const Color(0x33F59E0B),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: const Icon(Icons.warning_amber_rounded, color: Color(0xFFF59E0B), size: 16),
|
||||
child: const Icon(Icons.warning_amber_rounded,
|
||||
color: Color(0xFFF59E0B), size: 16),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Obstacle ahead',
|
||||
style: GoogleFonts.inter(
|
||||
color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500)),
|
||||
Text('2.1m — Haptic alert sent',
|
||||
style: GoogleFonts.inter(color: Colors.white60, fontSize: 11)),
|
||||
style:
|
||||
GoogleFonts.inter(color: Colors.white60, fontSize: 11)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
@ -234,9 +243,12 @@ class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerPr
|
||||
),
|
||||
child: Column(children: [
|
||||
Row(children: [
|
||||
Expanded(child: _bigBtn(const Color(0xCC1A56DB), Icons.mic, () {})),
|
||||
Expanded(
|
||||
child: _bigBtn(const Color(0xCC1A56DB), Icons.mic, () {})),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _bigBtn(const Color(0xCCDC2626), Icons.phone_in_talk, () {})),
|
||||
Expanded(
|
||||
child: _bigBtn(
|
||||
const Color(0xCCDC2626), Icons.phone_in_talk, () {})),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
@ -290,7 +302,8 @@ class _RadarPainter extends CustomPainter {
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2;
|
||||
for (final r in [48.0, 34.0, 20.0]) {
|
||||
paint.color = const Color(0xFF60EFAB).withValues(alpha: 0.15 + (50 - r) / 100);
|
||||
paint.color =
|
||||
const Color(0xFF60EFAB).withValues(alpha: 0.15 + (50 - r) / 100);
|
||||
canvas.drawCircle(center, r, paint);
|
||||
}
|
||||
paint
|
||||
@ -301,4 +314,4 @@ class _RadarPainter extends CustomPainter {
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
Manual application layer.
|
||||
|
||||
This layer is reserved for manual/TTS instruction state orchestration.
|
||||
@ -0,0 +1,3 @@
|
||||
Manual data layer.
|
||||
|
||||
This layer is reserved for local command and shortcut documentation data sources.
|
||||
@ -0,0 +1,3 @@
|
||||
Manual domain layer.
|
||||
|
||||
This layer is reserved for manual section entities and instruction use cases.
|
||||
@ -1,6 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/services/voice_command_handler.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_decorations.dart';
|
||||
import '../../shared/widgets/animations/animations.dart';
|
||||
import '../../shared/widgets/feature_page.dart';
|
||||
|
||||
class ManualScreen extends StatelessWidget {
|
||||
const ManualScreen({super.key});
|
||||
@ -8,16 +12,38 @@ class ManualScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final commands = VoiceCommandKey.values.map((key) => key.name).toList();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Manual')),
|
||||
body: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: commands.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
leading: const Icon(Icons.record_voice_over),
|
||||
title: Text(commands[index]),
|
||||
),
|
||||
return FeaturePage(
|
||||
title: 'Manual',
|
||||
subtitle: 'Voice command yang tersedia',
|
||||
child: ListView(
|
||||
children: [
|
||||
StaggerWrapper(
|
||||
children: [
|
||||
for (final command in commands)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardWhite,
|
||||
borderRadius: AppDecorations.cardRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppDecorations.cardShadow,
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: AppDecorations.iconCircle(),
|
||||
child: const Icon(
|
||||
Icons.record_voice_over,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
),
|
||||
title: Text(command),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user