Compare commits
No commits in common. "d2b3534ddeae5f79ae53b26810874f32ea76fde7" and "a629357e8c4b2c536791f60464b6236eec9eedb9" have entirely different histories.
d2b3534dde
...
a629357e8c
5
.gitignore
vendored
5
.gitignore
vendored
@ -40,13 +40,8 @@ build/
|
|||||||
|
|
||||||
.env
|
.env
|
||||||
*.env
|
*.env
|
||||||
walkguide-backend/demo/secrets.properties
|
|
||||||
|
|
||||||
walkguide-backend/demo/hs_err_pid*.log
|
walkguide-backend/demo/hs_err_pid*.log
|
||||||
walkguide-backend/demo/backend-run*.log
|
|
||||||
walkguide-backend/demo/src/main/resources/firebase/*.json
|
|
||||||
walkguide-mobile/walkguide_app/android/app/google-services*.json
|
|
||||||
walkguide-mobile/walkguide_app/ios/Runner/GoogleService-Info.plist
|
|
||||||
|
|
||||||
# Android SDK path (generated by Android Studio)
|
# Android SDK path (generated by Android Studio)
|
||||||
walkguide-mobile/walkguide_app/android/local.properties
|
walkguide-mobile/walkguide_app/android/local.properties
|
||||||
|
|||||||
790
README.md
790
README.md
@ -1,517 +1,489 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
> ⚠️ **WORK IN PROGRESS (WIP)** ⚠️
|
||||||
|
> *This repository is currently under active development. All code, data, and structures are subject to continuous changes.*
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<img src="assets/gambar.png" alt="WalkGuide Banner" width="720"/>
|
||||||
|
|
||||||
# WalkGuide: AI-Powered Navigation
|
# WalkGuide: AI-Powered Navigation
|
||||||
|
|
||||||
Integrated Mobile Application Project
|
**Integrated Mobile Application Project**
|
||||||
Flutter Mobile Frontend x Spring Boot Backend x OOAD
|
*Flutter Mobile Frontend × Spring Boot Backend × OOAD*
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
## Group Members
|
|
||||||
|
|
||||||
| Name | NIM | Responsibility |
|
| Name | NIM | Responsibility |
|
||||||
|---|---:|---|
|
|------|-----|---------------|
|
||||||
| Bambang Herlambang | 5803024019 | Mobile feature support, documentation, testing |
|
| Bambang Herlambang | 5803024019 | - |
|
||||||
| Jap Robertus | 5803024004 | Mobile feature support, documentation, testing |
|
| Jap Robertus | 5803024004 | - |
|
||||||
| Evan William | 5803024001 | Backend API, Flutter integration, architecture alignment |
|
| Evan William | 5803024001 | Backend Engineer (Spring Boot API & Flutter) |
|
||||||
|
|
||||||
## Project Status
|
[](https://flutter.dev/)
|
||||||
|
[](https://spring.io/projects/spring-boot)
|
||||||
|
[](https://www.postgresql.org/)
|
||||||
|
[](https://firebase.google.com/)
|
||||||
|
[](https://www.agora.io/)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
This repository is no longer a backend-only skeleton. The current codebase contains:
|

|
||||||
|

|
||||||
|
|
||||||
- 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.
|
[**System Architecture**](#system-architecture) · [**Tech Stack**](#tech-stack) · [**Implementations**](#implementations) · [**API Endpoints**](#api-endpoints) · [**Design Patterns**](#design-patterns) · [**Results**](#results) · [**Weekly Progress**](#weekly-progress)
|
||||||
- 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.
|
|
||||||
|
|
||||||
Primary demo target: Android APK connected to the Spring Boot backend.
|
</div>
|
||||||
Chrome/web can be used for UI/debug flows, but camera, native AI, SQLite FFI, and mobile permissions are Android-first.
|
|
||||||
|
|
||||||
## Overview
|
---
|
||||||
|
|
||||||
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.
|
## Overview — WalkGuide System
|
||||||
|
|
||||||
Important flows implemented or represented in the codebase:
|
**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?
|
||||||
|
|
||||||
- Register/Login with Guardian and User roles.
|
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.
|
||||||
- Pairing by generated pairing code / user identity flow.
|
|
||||||
- Guardian dashboard and tools screens.
|
**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.
|
||||||
- User SOS trigger, Guardian acknowledge and resolve.
|
|
||||||
- Guardian text and voice-note style notification sending.
|
---
|
||||||
- Notification read/read-all handling.
|
|
||||||
- Location update and Guardian live map support.
|
|
||||||
- AI configuration, voice command configuration, hardware shortcut configuration.
|
|
||||||
- WalkGuide obstacle detection pipeline integration points.
|
|
||||||
- Call token and call notification endpoints for Agora-style VoIP flow.
|
|
||||||
|
|
||||||
## System Architecture
|
## System Architecture
|
||||||
|
|
||||||
The project follows a feature-first architecture across backend, mobile, and OOAD documents.
|
The study follows a strict three-pillar enterprise structure:
|
||||||
|
|
||||||
### Backend
|
**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 follows layered Spring Boot architecture:
|
**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.
|
||||||
|
|
||||||
```text
|
**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.
|
||||||
Controller -> Service -> Repository -> Entity -> PostgreSQL
|
|
||||||
```
|
|
||||||
|
|
||||||
Main backend concerns:
|
---
|
||||||
|
|
||||||
- `controller/`: REST API endpoints.
|
|
||||||
- `service/`: application/business logic.
|
|
||||||
- `repository/`: Spring Data JPA persistence contracts.
|
|
||||||
- `entity/`: database-mapped entities.
|
|
||||||
- `dto/request` and `dto/response`: API input/output contracts.
|
|
||||||
- `security/`: JWT utility, JWT filter, custom user details service.
|
|
||||||
- `config/`: Security, WebSocket, OpenAPI, Firebase/FCM, seeding.
|
|
||||||
- `websocket/`: STOMP broadcasting helper.
|
|
||||||
- `db/migration/`: Flyway schema migrations.
|
|
||||||
|
|
||||||
### Flutter
|
|
||||||
|
|
||||||
Flutter uses a feature-first layout. Several critical features now have domain/data/application/presentation structure, while compatibility wrapper screens remain for the existing app routes.
|
|
||||||
|
|
||||||
Main Flutter concerns:
|
|
||||||
|
|
||||||
- `app/`: app shell, router, dependency injection.
|
|
||||||
- `core/`: API service/client, services, storage/cache, AI helpers, errors.
|
|
||||||
- `features/`: auth, server connect, pairing, SOS, notifications, WalkGuide, activity log, navigation, settings, call, guardian dashboard/tools, manual, benchmark.
|
|
||||||
- `shared/widgets/`: common UI shell and reusable feature page components.
|
|
||||||
|
|
||||||
### OOAD
|
|
||||||
|
|
||||||
The `ooad-docs/` folder contains traceability and diagrams, including:
|
|
||||||
|
|
||||||
- `01_Builder_Pattern.puml`
|
|
||||||
- `02_Singleton_Pattern.puml`
|
|
||||||
- `03_Facade_Pattern.puml`
|
|
||||||
- `04_Repository_Proxy_Pattern.puml`
|
|
||||||
- `05_Observer_Pattern.puml`
|
|
||||||
- `06_Strategy_Pattern.puml`
|
|
||||||
- `07_ChainOfResponsibility_Pattern.puml`
|
|
||||||
- Use case, sequence, state, ERD, class, and component diagrams under `ooad-docs/diagrams/`.
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
### Backend
|
### Backend (Spring Boot)
|
||||||
|
|
||||||
| Tool / Library | Current Codebase | Purpose |
|
| Library / Tool | Version | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Java | 21 | Backend language |
|
| Java | 21 | Primary language |
|
||||||
| Spring Boot | 3.2.5 | Main backend framework |
|
| Spring Boot | 3.3.x | Main framework |
|
||||||
| Spring Security | Spring Boot managed | JWT auth and RBAC |
|
| Spring Security | (bundled) | Auth + RBAC |
|
||||||
| Spring Data JPA | Spring Boot managed | ORM and repositories |
|
| Spring Data JPA | (bundled) | ORM |
|
||||||
| Spring WebSocket | Spring Boot managed | STOMP realtime channels |
|
| Spring WebSocket (STOMP) | (bundled) | Real-time location & notification push |
|
||||||
| PostgreSQL Driver | Runtime dependency | University PostgreSQL database |
|
| PostgreSQL Driver | (bundled) | DB connection — university server `202.46.28.160` |
|
||||||
| Flyway | Core + PostgreSQL module | Database migrations |
|
| Flyway | 10.x | Database schema migration |
|
||||||
| JJWT | 0.11.5 | JWT access token handling |
|
| JJWT | 0.11.5 | JWT access + refresh token |
|
||||||
| Springdoc OpenAPI | 2.3.0 | Swagger/OpenAPI docs |
|
| Firebase Admin SDK | 9.x | FCM push notifications |
|
||||||
| Lombok | 1.18.36 | Builders and boilerplate reduction |
|
| Agora RESTful API | - | Generate Agora RTC token for VoIP |
|
||||||
| JUnit 5 / Mockito / MockMvc | Test dependencies | Unit and controller testing |
|
| Springdoc OpenAPI | 2.3.0 | Swagger UI documentation |
|
||||||
| Testcontainers | 1.19.7 | PostgreSQL-backed integration tests |
|
| Lombok | latest | Boilerplate reduction |
|
||||||
| JaCoCo | 0.8.11 | Coverage report |
|
| JUnit 5 + Mockito | (bundled) | Unit testing |
|
||||||
|
| MockMvc + Testcontainers | 1.19.x | Integration testing with real PostgreSQL |
|
||||||
|
| JaCoCo | 0.8.x | Code coverage (target ≥ 70%) |
|
||||||
|
|
||||||
### Flutter
|
### Flutter (Mobile)
|
||||||
|
|
||||||
| Package | Current Codebase | Purpose |
|
| Package | Version | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| flutter_bloc | 8.1.6 | Cubit/BLoC state management |
|
| flutter_bloc | 8.x | State management (sole pattern) |
|
||||||
| go_router | 14.2.7 | App routing |
|
| go_router | 14.x | Navigation + role-based route guards |
|
||||||
| dio | 5.4.3+1 | REST client |
|
| dio | 5.x | HTTP client with interceptors |
|
||||||
| flutter_secure_storage | 9.2.2 | Secure token storage on mobile |
|
| shared_preferences | 2.x | Persist dynamic server URL |
|
||||||
| shared_preferences | 2.3.2 | Server URL and web cache fallback |
|
| flutter_secure_storage | 10.x | Secure JWT token storage |
|
||||||
| drift / sqlite3 | drift 2.18.0, sqlite3 2.4.7 | Local persistence support |
|
| drift | 2.x | SQLite ORM for offline cache |
|
||||||
| sqlite3_flutter_libs | 0.5.24 | Android/iOS SQLite native libs |
|
| tflite_flutter | 0.10.x | Run YOLOv8n on-device |
|
||||||
| camera | 0.11.0+2 | Camera feed |
|
| camera | 0.10.x | Camera feed for YOLO inference |
|
||||||
| tflite_flutter | 0.12.1 | On-device model inference |
|
| flutter_tts | 4.x | Text-to-Speech (ID + EN) |
|
||||||
| flutter_tts | 4.0.2 | Text-to-speech |
|
| speech_to_text | 6.x | Always-listening voice commands |
|
||||||
| speech_to_text | 7.0.0 | Voice recognition support |
|
| agora_rtc_engine | 6.x | VoIP call Guardian ↔ User |
|
||||||
| firebase_core / firebase_messaging | 3.3.0 / 15.1.0 | FCM integration |
|
| firebase_messaging | 14.x | FCM push notification receiver |
|
||||||
| flutter_local_notifications | 17.2.1+2 | Foreground/local notification UI |
|
| flutter_map | 6.x | OpenStreetMap (free, no API key) |
|
||||||
| flutter_map / latlong2 | 7.0.2 / 0.9.1 | OpenStreetMap UI |
|
| geolocator | 11.x | Real-time GPS |
|
||||||
| geolocator | 12.0.0 | GPS/location |
|
| stomp_dart_client | 2.x | WebSocket STOMP for live tracking |
|
||||||
| agora_rtc_engine | 6.3.2 | VoIP engine integration |
|
| get_it | 7.x | Service locator / dependency injection |
|
||||||
| just_audio / record | 0.9.40 / 5.1.2 | Voice note playback/recording |
|
| dartz | 0.10.x | `Either<Failure, Data>` typed error handling |
|
||||||
| get_it | 8.0.2 | Dependency injection |
|
| vibration | 1.x | Haptic feedback on obstacle detection |
|
||||||
| dartz | 0.10.1 | Either-style error handling |
|
|
||||||
| connectivity_plus | 6.0.3 | Offline/online detection |
|
|
||||||
|
|
||||||
## Runtime Configuration
|
### External Services
|
||||||
|
|
||||||
### Backend
|
| 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) |
|
||||||
|
|
||||||
Local/dev config imports an optional gitignored file at `walkguide-backend/demo/secrets.properties`.
|
---
|
||||||
Copy `walkguide-backend/demo/secrets.properties.example` to `secrets.properties` and fill it locally.
|
|
||||||
Tracked config files do not contain DB passwords, JWT secrets, Agora certificates, or Firebase keys.
|
|
||||||
|
|
||||||
Both dev and production expect these values from environment variables or `secrets.properties`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
DB_URL
|
|
||||||
DB_USERNAME
|
|
||||||
DB_PASSWORD
|
|
||||||
JWT_SECRET
|
|
||||||
AGORA_APP_ID
|
|
||||||
AGORA_APP_CERTIFICATE
|
|
||||||
```
|
|
||||||
|
|
||||||
### Flutter
|
|
||||||
|
|
||||||
The Flutter app uses a dynamic server URL. On first launch, use the Server Connect screen and enter a backend URL such as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://202.46.28.170:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
For testing on a physical phone against a backend running on a laptop, do not use `localhost`. Use the laptop LAN IP:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://192.168.x.x:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementations
|
## Implementations
|
||||||
|
|
||||||
### User Mode
|
### 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.
|
||||||
|
|
||||||
- WalkGuide camera screen and AI pipeline integration points.
|
### Design B — Guardian Mode (Admin/Caregiver)
|
||||||
- TTS feedback and friendly error handling.
|
A command center dashboard for oversight and remote configuration.
|
||||||
- SOS screen with emergency action flow.
|
- **Real-time Live Map:** Tracks User's GPS location via WebSocket STOMP subscription, rendered on OpenStreetMap with `flutter_map`. Includes geofence circle overlay.
|
||||||
- Notifications screen with read/read-all actions.
|
- **Remote AI Configuration:** Adjusts YOLO confidence threshold, alert distance thresholds, max inference FPS, and enabled object labels.
|
||||||
- Activity log, navigation, settings, pairing, and manual screens.
|
- **Voice Command Management:** Edits trigger phrases and enables/disables any of the 14 voice commands remotely.
|
||||||
- Voice command and shortcut configuration retrieval paths.
|
- **Notifications:** Sends text messages or voice notes (recorded audio) directly to the User's device.
|
||||||
- Offline queue/cache support for core app data.
|
- **Geofence:** Sets a geographic boundary — backend notifies Guardian via FCM when User exits.
|
||||||
|
|
||||||
### Guardian Mode
|
---
|
||||||
|
|
||||||
- Guardian dashboard and tools.
|
|
||||||
- Guardian live map screen.
|
|
||||||
- Guardian activity log screen.
|
|
||||||
- Send notification screen with text and voice note modes.
|
|
||||||
- AI config screen.
|
|
||||||
- Voice command and shortcut management screens.
|
|
||||||
- Geofence/settings screens.
|
|
||||||
- SOS acknowledge and resolve support.
|
|
||||||
|
|
||||||
### Backend API
|
|
||||||
|
|
||||||
- Auth, pairing, user, guardian, and shared call controllers.
|
|
||||||
- Service layer for pairing, location, activity, obstacles, notifications, SOS, AI config, voice commands, hardware shortcuts, geofence, user settings, call notifications, and dashboard aggregation.
|
|
||||||
- WebSocket broadcaster for location/SOS/notification-style real-time updates.
|
|
||||||
- Flyway-managed PostgreSQL schema.
|
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
Database: PostgreSQL on the university server.
|
Database runs on PostgreSQL at `202.46.28.160:2002`. Schema is managed exclusively by Flyway migrations. `spring.jpa.hibernate.ddl-auto=validate`.
|
||||||
Schema management: Flyway.
|
|
||||||
Hibernate mode: `validate`.
|
|
||||||
|
|
||||||
Current migrations in `walkguide-backend/demo/src/main/resources/db/migration/`:
|
| Migration | Table | Status |
|
||||||
|
|
||||||
| Migration | File | Status |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| V1 | `V1__create_users_table.sql` | Present |
|
| V1 | `users` | ✅ Exists |
|
||||||
| V2 | `V2__seed_users.sql` | Present |
|
| V2 | Seed data | ✅ Exists |
|
||||||
| V3 | `V3__link_guardian_user.sql` | Present |
|
| V3 | Guardian-User link | ✅ Exists |
|
||||||
| V4 | `V4__alter_users_add_columns.sql` | Present |
|
| V4 | Add `unique_user_id` column | 🔄 Needs creation |
|
||||||
| V5 | `V5__create_pairing_relations.sql` | Present |
|
| V5 | `pairing_relations` | 🔄 Needs creation |
|
||||||
| V6 | `V6__create_activity_logs.sql` | Present |
|
| V6 | `activity_logs` | 🔄 Needs creation |
|
||||||
| V7 | `V7__create_obstacle_logs.sql` | Present |
|
| V7 | `obstacle_logs` | 🔄 Needs creation |
|
||||||
| V8 | `V8__create_location_history.sql` | Present |
|
| V8 | `location_history` | 🔄 Needs creation |
|
||||||
| V9 | `V9__create_guardian_notifications.sql` | Present |
|
| V9 | `guardian_notifications` | 🔄 Needs creation |
|
||||||
| V10 | `V10__create_sos_events.sql` | Present |
|
| V10 | `sos_events` | 🔄 Needs creation |
|
||||||
| V11 | `V11__create_user_settings.sql` | Present |
|
| V11 | `user_settings` | 🔄 Needs creation |
|
||||||
| V12 | `V12__create_ai_configs.sql` | Present |
|
| V12 | `ai_configs` | 🔄 Needs creation |
|
||||||
| V13 | `V13__create_voice_command_configs.sql` | Present |
|
| V13 | `voice_command_configs` | 🔄 Needs creation |
|
||||||
| V14 | `V14__create_hardware_shortcuts.sql` | Present |
|
| V14 | `hardware_shortcuts` | 🔄 Needs creation |
|
||||||
| V15 | `V15__create_geofence_configs.sql` | Present |
|
| V15 | `geofence_configs` | 🔄 Needs creation |
|
||||||
| V16 | `V16__create_refresh_tokens.sql` | Present |
|
| V16 | `refresh_tokens` | 🔄 Needs creation |
|
||||||
| V17 | `V17__add_expiring_pairing_codes.sql` | Present |
|
|
||||||
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### Auth - `/api/v1/auth`
|
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`
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/ping` | Server connection check |
|
| GET | `/dashboard` | Combined home data (user status, recent logs, SOS count) |
|
||||||
| POST | `/register` | Register Guardian or User |
|
| GET | `/user-status` | Full status of paired User |
|
||||||
| POST | `/login` | Login |
|
| GET | `/user-location` | Last known GPS location |
|
||||||
| POST | `/refresh` | Refresh access token |
|
|
||||||
| POST | `/logout` | Logout |
|
|
||||||
| PUT | `/fcm-token` | Update FCM token |
|
|
||||||
|
|
||||||
### Pairing - `/api/v1/shared/pairing`
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/code` | Get current pairing code |
|
|
||||||
| POST | `/code/regenerate` | Regenerate pairing code |
|
|
||||||
| POST | `/invite` | Guardian invites User |
|
|
||||||
| POST | `/respond` | User accepts/rejects invite |
|
|
||||||
| DELETE | `/unpair` | Remove pairing |
|
|
||||||
| GET | `/status` | Get current pairing status |
|
|
||||||
|
|
||||||
### Guardian - `/api/v1/guardian`
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/dashboard` | Guardian home data |
|
|
||||||
| GET | `/user-location` | Last known User location |
|
|
||||||
| GET | `/location-history` | Paginated location history |
|
| GET | `/location-history` | Paginated location history |
|
||||||
| GET | `/activity-logs` | Paginated User activity logs |
|
| GET | `/activity-logs` | Paginated activity logs of paired User |
|
||||||
| GET | `/obstacle-logs` | Paginated obstacle logs |
|
| GET | `/obstacle-logs` | Paginated obstacle detection logs |
|
||||||
| POST | `/notifications/send` | Send text or voice note notification |
|
| POST | `/notifications/send` | Send text or voice note to User |
|
||||||
| GET | `/sos-events` | Paginated SOS events |
|
| GET | `/sos-events` | Paginated SOS events |
|
||||||
| PUT | `/sos/{id}/acknowledge` | Mark SOS as acknowledged |
|
| PUT | `/sos/{id}/acknowledge` | Acknowledge SOS alert |
|
||||||
| PUT | `/sos/{id}/resolve` | Mark SOS as resolved/handled |
|
| GET/PUT | `/ai-config` | Get or update AI configuration |
|
||||||
| GET/PUT | `/ai-config` | Get/update AI config |
|
| GET/PUT | `/voice-commands` | Get or update voice command configs |
|
||||||
| GET/PUT | `/voice-commands` | Get/update voice command config |
|
| GET/PUT | `/shortcuts` | Get or update hardware shortcut configs |
|
||||||
| GET/PUT | `/shortcuts` | Get/update shortcut config |
|
| GET/PUT | `/geofence` | Get or update geofence config |
|
||||||
| GET/PUT | `/geofence` | Get/update geofence config |
|
|
||||||
| GET/PUT | `/user-settings` | Get/update paired User settings |
|
|
||||||
|
|
||||||
### User - `/api/v1/user`
|
**User** — `/api/v1/user`
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/profile` | Current User profile |
|
| GET | `/profile` | User profile (id, email, displayName, uniqueUserId) |
|
||||||
| GET/PUT | `/settings` | Get/update User settings |
|
| GET/PUT | `/settings` | Get or update TTS/haptic settings |
|
||||||
| GET | `/voice-commands` | Get voice command config |
|
| GET | `/voice-commands` | Get all voice commands (read-only) |
|
||||||
| GET/PUT | `/shortcuts` | Get/update shortcut config |
|
| GET/PUT | `/shortcuts` | Get or capture hardware shortcut assignments |
|
||||||
| GET | `/ai-config` | Get AI config |
|
| GET | `/ai-config` | Get AI config (read-only) |
|
||||||
| POST | `/location` | Send location update |
|
| POST | `/location` | Send GPS update |
|
||||||
| POST | `/obstacle` | Log obstacle |
|
| POST | `/obstacle` | Log detected obstacle |
|
||||||
| POST | `/sos` | Trigger SOS |
|
| POST | `/sos` | Trigger SOS alert |
|
||||||
| GET | `/sos-events` | Get own SOS events |
|
| GET | `/activity-logs` | Paginated own activity log |
|
||||||
| GET | `/activity-logs` | Get activity logs |
|
| GET | `/notifications` | Paginated notifications from Guardian |
|
||||||
| GET | `/notifications` | Get notifications |
|
| GET | `/notifications/unread-count` | Unread notification count |
|
||||||
| GET | `/notifications/unread-count` | Get unread count |
|
| PUT | `/notifications/mark-all-read` | Mark all as read |
|
||||||
| PUT | `/notifications/mark-all-read` | Mark all notifications read |
|
| PUT | `/notifications/{id}/read` | Mark one as read |
|
||||||
| PUT | `/notifications/{id}/read` | Mark one notification read |
|
| POST | `/walkguide/start` | Log WalkGuide session start |
|
||||||
| POST | `/walkguide/start` | Log WalkGuide start |
|
| POST | `/walkguide/stop` | Log WalkGuide session stop |
|
||||||
| POST | `/walkguide/stop` | Log WalkGuide stop |
|
|
||||||
|
|
||||||
### Shared Call - `/api/v1/shared/call`
|
**Shared Call** — `/api/v1/shared/call`
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST | `/token` | Generate call token/channel payload |
|
| POST | `/token` | Generate Agora RTC token |
|
||||||
| POST | `/notify` | Notify other party of incoming call |
|
| POST | `/notify` | Send "Incoming Call" FCM to other party |
|
||||||
| POST | `/end` | Notify/end call session |
|
|
||||||
|
---
|
||||||
|
|
||||||
## Design Patterns
|
## Design Patterns
|
||||||
|
|
||||||
The project documents and maps 7 GoF-related patterns in `ooad-docs/`.
|
7 GoF Design Patterns implemented (exam minimum: 4, minimum 1 per category).
|
||||||
|
|
||||||
| # | Category | Pattern | Main Location |
|
| # | Category | Pattern | Location |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | Creational | Builder | Backend entities/DTO construction, Lombok builders |
|
| 1 | Creational | **Builder** | `User.java` (`@Builder`), `FcmService` message construction |
|
||||||
| 2 | Creational | Singleton | Flutter services registered through GetIt |
|
| 2 | Creational | **Singleton** | Flutter: `TtsService`, `YoloDetector`, `WebSocketService`, `AgoraService` via GetIt |
|
||||||
| 3 | Structural | Facade | Guardian dashboard aggregation and voice command/service coordination |
|
| 3 | Structural | **Facade** | Flutter: `VoiceCommandHandler`; Backend: `GuardianDashboardService` |
|
||||||
| 4 | Structural | Repository / Proxy | Spring Data repositories and Flutter repository-style data access |
|
| 4 | Structural | **Repository (Proxy)** | All `*_repository_impl.dart` — proxy between domain and data sources |
|
||||||
| 5 | Behavioral | Observer | BLoC/Cubit state listeners and WebSocket callbacks |
|
| 5 | Behavioral | **Observer** | BLoC pattern (BLoC = Subject, Widgets = Observers); WebSocket callbacks |
|
||||||
| 6 | Behavioral | Strategy | Obstacle analysis / alert behavior mapping documented in OOAD |
|
| 6 | Behavioral | **Strategy** | `ObstacleAnalyzer` direction strategy; `ObstacleAlertStrategyService` (backend) |
|
||||||
| 7 | Behavioral | Chain of Responsibility | Spring Security filter chain and Dio interceptors |
|
| 7 | Behavioral | **Chain of Responsibility** | Spring Security filter chain; Dio interceptor chain |
|
||||||
|
|
||||||
See:
|
---
|
||||||
|
|
||||||
- `ooad-docs/DESIGN_PATTERNS.md`
|
## Metrics
|
||||||
- `ooad-docs/TRACEABILITY_AUDIT.md`
|
|
||||||
- `ooad-docs/01_Builder_Pattern.puml` through `ooad-docs/07_ChainOfResponsibility_Pattern.puml`
|
|
||||||
|
|
||||||
## Metrics And Evidence
|
| 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 |
|
||||||
|
|
||||||
The repository includes testing and benchmark support, but final scoring evidence should be generated on the target machine/device before submission.
|
---
|
||||||
|
|
||||||
| Area | Current Support |
|
|
||||||
|---|---|
|
|
||||||
| Backend unit tests | JUnit/Mockito tests under `src/test/java` |
|
|
||||||
| Backend controller/integration tests | MockMvc and Testcontainers setup present |
|
|
||||||
| Coverage | JaCoCo configured in Maven |
|
|
||||||
| Load testing | k6 assets/results folder present under backend project |
|
|
||||||
| Flutter tests | Unit/widget/integration test files present |
|
|
||||||
| Flutter performance | Benchmark evidence template in `ooad-docs/BENCHMARK_EVIDENCE_TEMPLATE.md` |
|
|
||||||
|
|
||||||
Recommended final evidence:
|
|
||||||
|
|
||||||
- `mvn test` or `mvn verify` output.
|
|
||||||
- JaCoCo HTML report.
|
|
||||||
- Testcontainers run on a Docker-enabled machine.
|
|
||||||
- k6 result at 50 or more concurrent virtual users.
|
|
||||||
- Flutter physical Android profile evidence: cold start, memory, jank/frame timing, CPU during AI, API latency, APK size.
|
|
||||||
|
|
||||||
## Repository Structure
|
## Repository Structure
|
||||||
|
|
||||||
|
> 🚨 **DISCLAIMER:** This repository is in active development. The directory tree below reflects the intended final architecture. Flutter implementation is currently pending — backend (Spring Boot) is the active development phase.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/
|
/
|
||||||
|-- walkguide-backend/
|
├── walkguide-backend/ # [Spring Boot Engine — ACTIVE]
|
||||||
| |-- demo/
|
│ ├── src/main/java/com/walkguide/
|
||||||
| |-- src/main/java/com/walkguide/
|
│ │ ├── config/ # SecurityConfig, WebSocketConfig, OpenApiConfig, FcmConfig, DataSeeder
|
||||||
| | |-- config/
|
│ │ ├── enums/ # UserRole, PairingStatus, ActivityLogType, VoiceCommandKey,
|
||||||
| | |-- controller/
|
│ │ │ # HardwareShortcutKey, NotificationType, SosStatus
|
||||||
| | |-- dto/
|
│ │ ├── entity/ # User, PairingRelation, ActivityLog, ObstacleLog,
|
||||||
| | |-- entity/
|
│ │ │ # LocationHistory, GuardianNotification, SosEvent,
|
||||||
| | |-- enums/
|
│ │ │ # UserSettings, AiConfig, VoiceCommandConfig,
|
||||||
| | |-- exception/
|
│ │ │ # HardwareShortcut, GeofenceConfig, RefreshToken
|
||||||
| | |-- repository/
|
│ │ ├── repository/ # JPA repositories for all entities
|
||||||
| | |-- security/
|
│ │ ├── dto/
|
||||||
| | |-- service/
|
│ │ │ ├── request/ # RegisterRequest, LoginRequest, InviteUserRequest,
|
||||||
| | `-- websocket/
|
│ │ │ │ # LocationUpdateRequest, ObstacleLogRequest,
|
||||||
| |-- src/main/resources/
|
│ │ │ │ # SendNotificationRequest, SosRequest,
|
||||||
| | |-- application.properties
|
│ │ │ │ # AiConfigUpdateRequest, VoiceCommandUpdateRequest,
|
||||||
| | |-- application-dev.yml
|
│ │ │ │ # HardwareShortcutUpdateRequest, GeofenceConfigRequest,
|
||||||
| | |-- application-prod.yml
|
│ │ │ │ # UserSettingsUpdateRequest, and more
|
||||||
| | `-- db/migration/V1...V17
|
│ │ │ └── response/ # ApiResponse (standard wrapper), AuthDataResponse,
|
||||||
| |-- src/test/java/com/walkguide/
|
│ │ │ # UserProfileResponse, PairingStatusResponse,
|
||||||
| |-- k6-tests/
|
│ │ │ # ActivityLogResponse, ObstacleLogResponse,
|
||||||
| `-- pom.xml
|
│ │ │ # LocationResponse, NotificationResponse,
|
||||||
|
|
│ │ │ # SosEventResponse, AgoraTokenResponse,
|
||||||
|-- walkguide-mobile/
|
│ │ │ # AiConfigResponse, VoiceCommandResponse,
|
||||||
| `-- walkguide_app/
|
│ │ │ # HardwareShortcutResponse, GeofenceResponse
|
||||||
| |-- lib/
|
│ │ ├── service/ # AuthService, PairingService, ActivityLogService,
|
||||||
| | |-- app/
|
│ │ │ # LocationService, ObstacleLogService,
|
||||||
| | |-- core/
|
│ │ │ # NotificationService, SosService,
|
||||||
| | |-- features/
|
│ │ │ # AiConfigService, VoiceCommandService,
|
||||||
| | `-- shared/
|
│ │ │ # HardwareShortcutService, GeofenceService,
|
||||||
| |-- assets/
|
│ │ │ # UserSettingsService, FcmService,
|
||||||
| | |-- images/
|
│ │ │ # AgoraTokenService, GuardianDashboardService
|
||||||
| | `-- models/
|
│ │ ├── controller/ # AuthController, PairingController,
|
||||||
| |-- test/
|
│ │ │ # GuardianController, UserController, CallController
|
||||||
| |-- integration_test/
|
│ │ ├── security/ # JwtUtil, JwtAuthFilter, CustomUserDetailsService
|
||||||
| `-- pubspec.yaml
|
│ │ ├── websocket/ # LocationBroadcaster (STOMP broadcaster)
|
||||||
|
|
│ │ └── exception/ # GlobalExceptionHandler, ResourceNotFoundException,
|
||||||
|-- ooad-docs/
|
│ │ # UnauthorizedException, PairingException
|
||||||
| |-- 01_Builder_Pattern.puml
|
│ ├── src/main/resources/
|
||||||
| |-- 02_Singleton_Pattern.puml
|
│ │ ├── application.properties # DB config (PostgreSQL 202.46.28.160:2002), Flyway, JWT
|
||||||
| |-- 03_Facade_Pattern.puml
|
│ │ ├── firebase/
|
||||||
| |-- 04_Repository_Proxy_Pattern.puml
|
│ │ │ └── google-services-admin.json # FCM service account key (gitignored!)
|
||||||
| |-- 05_Observer_Pattern.puml
|
│ │ └── db/migration/
|
||||||
| |-- 06_Strategy_Pattern.puml
|
│ │ ├── V1__create_users_table.sql ✅ Exists
|
||||||
| |-- 07_ChainOfResponsibility_Pattern.puml
|
│ │ ├── V2__seed_users.sql ✅ Exists
|
||||||
| `-- diagrams/
|
│ │ ├── V3__link_guardian_user.sql ✅ Exists
|
||||||
|
|
│ │ ├── V4__add_unique_user_id.sql 🔄 Needs creation
|
||||||
|-- FULL_FLOW_ARCHITECTURE.md
|
│ │ ├── V5__create_pairing_relations.sql 🔄 Needs creation
|
||||||
|-- FINAL_EXAM_GUIDE.md
|
│ │ ├── V6__create_activity_logs.sql 🔄 Needs creation
|
||||||
|-- TODO.md
|
│ │ ├── V7__create_obstacle_logs.sql 🔄 Needs creation
|
||||||
`-- README.md
|
│ │ ├── 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Feature Flows
|
---
|
||||||
|
|
||||||
### Flow 1 - Register And Pairing
|
## Feature Flows (High-Level)
|
||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
### Flow 2 - WalkGuide Detection
|
### 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.
|
||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
### Flow 3 - SOS Alert
|
### 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.
|
||||||
|
|
||||||
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 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."
|
||||||
|
|
||||||
### Flow 4 - Guardian Notification
|
### 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.
|
||||||
|
|
||||||
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.
|
---
|
||||||
|
|
||||||
### Flow 5 - Location And Map
|
## Quick Start (WIP)
|
||||||
|
|
||||||
User sends location updates -> backend stores location history -> Guardian map/dashboard reads the latest location and history -> WebSocket support exists for real-time updates.
|
```bash
|
||||||
|
# 1. Clone the repository
|
||||||
|
git clone https://github.com/YourGroup/walkguide-final-exam.git
|
||||||
|
|
||||||
### Flow 6 - Call
|
# 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
|
||||||
|
|
||||||
Caller requests token/channel -> backend returns call token payload -> caller notifies target -> target receives incoming call flow -> call can be ended through shared endpoint.
|
# 3. Run Mobile (Flutter) - Connect to local or university backend
|
||||||
|
cd ../walkguide-mobile
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd "D:\CodeSpace\Final Project Gabungan - Broken Test\walkguide-backend\demo"
|
|
||||||
.\mvnw.cmd spring-boot:run -Dspring-boot.run.profiles=dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Health check:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://localhost:8080/api/v1/auth/ping
|
|
||||||
```
|
|
||||||
|
|
||||||
Swagger UI:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://localhost:8080/swagger-ui.html
|
|
||||||
```
|
|
||||||
|
|
||||||
Local/dev configuration reads database and secret values from environment variables or from the gitignored `secrets.properties` file:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:DB_URL="jdbc:postgresql://<host>:<port>/<database>"
|
|
||||||
$env:DB_USERNAME="<database_username>"
|
|
||||||
$env:DB_PASSWORD="<database_password>"
|
|
||||||
$env:JWT_SECRET="your-base64-secret"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Flutter
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd "D:\CodeSpace\Final Project Gabungan - Broken Test\walkguide-mobile\walkguide_app"
|
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter run
|
flutter run
|
||||||
|
# On first launch: enter server URL in ServerConnectScreen
|
||||||
|
# Example: http://202.46.28.160:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
For Chrome/web debug:
|
---
|
||||||
|
|
||||||
```powershell
|
|
||||||
flutter run -d chrome -t lib/main.dart
|
|
||||||
```
|
|
||||||
|
|
||||||
For Android APK:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
flutter build apk --release
|
|
||||||
```
|
|
||||||
|
|
||||||
On a physical phone, the server URL must be reachable by the phone. Use the university server URL or your laptop LAN IP, not `localhost`.
|
|
||||||
|
|
||||||
## Results
|
## Results
|
||||||
|
|
||||||
Final benchmark values should be filled from real test runs before submission.
|
> ⏳ **Work In Progress:** Results are populated as benchmarking phases are completed.
|
||||||
|
|
||||||
| Metric | Evidence Location / Tool | Current README Status |
|
| Metric | Baseline | Final Optimized | Status |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| Backend tests | Maven/JUnit output | To be generated |
|
| Cold Start Time | *Pending* | *Pending* | ⏳ |
|
||||||
| Backend coverage | JaCoCo report | To be generated |
|
| Memory Leak (10 Navs) | *Pending* | *Pending* | ⏳ |
|
||||||
| Backend load | k6 results | Assets present, final run needed |
|
| API Error Rate | *Pending* | *Pending* | ⏳ |
|
||||||
| Flutter tests | `flutter test` / integration tests | To be generated |
|
| YOLO Inference Latency (ms) | *Pending* | *Pending* | ⏳ |
|
||||||
| Flutter performance | Physical Android profile evidence | To be generated |
|
| API p95 Latency | *Pending* | *Pending* | ⏳ |
|
||||||
| APK size | `flutter build apk --analyze-size` | To be generated |
|
| JaCoCo Coverage | *Pending* | *Pending* | ⏳ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Weekly Progress
|
## Weekly Progress
|
||||||
|
|
||||||
| Week | Target | Current Status |
|
| Week | Target | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | Topic proposal, use case definitions, repo setup | Done |
|
| 1 | Topic proposal, Use Case definitions, Repo setup | ✅ Done |
|
||||||
| 2-3 | OOAD diagrams and traceability | Done, docs present in `ooad-docs/` |
|
| 2–3 | OOAD diagrams, OpenAPI YAML drafted | 🔄 In Progress |
|
||||||
| 4 | Spring Boot auth, pairing, entities, migrations | Implemented |
|
| 4 | Spring Boot: Auth, Pairing, Entity, Migration V4–V16 | 🔄 In Progress |
|
||||||
| 5 | Location, SOS, notification, WebSocket, FCM, call support | Implemented with demo/service integrations |
|
| 5 | Spring Boot: Location, SOS, Notification, WS, FCM, Agora | ⏳ Pending |
|
||||||
| 6 | Backend unit/integration testing and coverage setup | Implemented, final run evidence needed |
|
| 6 | Spring Boot: Unit + Integration tests, JaCoCo ≥ 70% | ⏳ Pending |
|
||||||
| 7 | Flutter server connect, auth, WalkGuide/YOLO support | Implemented |
|
| 7 | Flutter: ServerConnect, Auth, WalkGuide + YOLO pipeline | ⏳ Pending |
|
||||||
| 8 | Guardian dashboard, SOS, notifications, voice notes, settings | Implemented |
|
| 8 | Flutter: Guardian dashboard, Call, SOS, Notifications | ⏳ Pending |
|
||||||
| 9 | Integration testing and benchmark evidence | Needs final evidence run |
|
| 9 | Feature freeze, integration testing, benchmark on device | ⏳ Pending |
|
||||||
| 10 | Report, demo video, final submission polish | In progress |
|
| 10 | Final benchmarks, Report writing, Demo Video | ⏳ Pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Core Objectives
|
## Core Objectives
|
||||||
|
|
||||||
O1 Performance: Keep obstacle detection local/on-device where possible and use backend for persistence, pairing, configuration, and real-time coordination.
|
**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.
|
||||||
|
|
||||||
O2 Accessibility: Support voice/TTS, haptic feedback, large touch targets, and hardware shortcut flows for visually impaired users.
|
**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.
|
||||||
|
|
||||||
O3 Traceability: Keep implementation aligned with `FULL_FLOW_ARCHITECTURE.md`, `FINAL_EXAM_GUIDE.md`, and the PUML diagrams in `ooad-docs/`.
|
**O₃ (Traceability):** Every major feature implementation must be directly traceable back to the pre-development OOAD artifacts and GoF design patterns.
|
||||||
|
|
||||||
O4 Configurability: Let Guardian configure AI sensitivity, voice commands, hardware shortcuts, geofence, and User settings through dashboard flows.
|
**O₄ (Configurability):** All AI sensitivity settings, voice command phrases, and hardware shortcuts are remotely configurable by the Guardian without requiring an app update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Distributed under the [MIT License](LICENSE).
|
Distributed under the [MIT License](LICENSE).
|
||||||
|
|
||||||
Final Exam: Integrated Mobile Application Project
|
---
|
||||||
Flutter x Spring Boot x Object-Oriented Analysis and Design
|
|
||||||
|
*Final Exam: Integrated Mobile Application Project* <br>
|
||||||
|
*Flutter × Spring Boot × Object-Oriented Analysis and Design*
|
||||||
|
|||||||
3023
hs_err_pid17212.log
3023
hs_err_pid17212.log
File diff suppressed because it is too large
Load Diff
@ -46,14 +46,16 @@ package "③ Facade Pattern [Structural]" #FFF8E1 {
|
|||||||
class "VoiceCommandHandler\n<<Facade>>" as VoiceCommandHandler {
|
class "VoiceCommandHandler\n<<Facade>>" as VoiceCommandHandler {
|
||||||
- _ttsService : TtsService
|
- _ttsService : TtsService
|
||||||
- _sttService : SttService
|
- _sttService : SttService
|
||||||
- _router : CommandRouter
|
- _router : GoRouter
|
||||||
- _actions : Map<VoiceCommandKey, CommandAction>
|
- _walkGuideBloc : WalkGuideBloc
|
||||||
|
- _sosBloc : SosBloc
|
||||||
|
- _notifBloc : NotificationBloc
|
||||||
+ processText(String command) : void
|
+ processText(String command) : void
|
||||||
- _matchCommand(String) : VoiceCommandKey?
|
- _matchCommand(String) : VoiceCommandKey?
|
||||||
- _executeCommand(VoiceCommandKey) : void
|
- _executeCommand(VoiceCommandKey) : void
|
||||||
}
|
}
|
||||||
|
|
||||||
class "WalkGuideCubit\n<<Client>>" as WalkGuideCubitFacade {
|
class "WalkGuideBloc\n<<Client>>" as WalkGuideBlocFacade {
|
||||||
+ onVoiceCommand(String text)
|
+ onVoiceCommand(String text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,8 +69,8 @@ package "③ Facade Pattern [Structural]" #FFF8E1 {
|
|||||||
|
|
||||||
class "SttService " as SttServiceFacade <<Subsystem>>
|
class "SttService " as SttServiceFacade <<Subsystem>>
|
||||||
class "TtsService " as TtsServiceFacade <<Subsystem>>
|
class "TtsService " as TtsServiceFacade <<Subsystem>>
|
||||||
class "CommandRouter\n<<Router Adapter>>" as GoRouterFacade <<Subsystem>>
|
class "GoRouter\n<<Router>>" as GoRouterFacade <<Subsystem>>
|
||||||
class "CommandAction\n<<Cubit Callback>>" as CommandActionFacade <<Subsystem>>
|
class "SosBloc " as SosBlocFacade <<Subsystem>>
|
||||||
|
|
||||||
class "LocationService\n<<Service>>" as LocationService <<Subsystem>>
|
class "LocationService\n<<Service>>" as LocationService <<Subsystem>>
|
||||||
class "ActivityLogService\n<<Service>>" as ActivityService <<Subsystem>>
|
class "ActivityLogService\n<<Service>>" as ActivityService <<Subsystem>>
|
||||||
@ -80,11 +82,11 @@ package "③ Facade Pattern [Structural]" #FFF8E1 {
|
|||||||
' GET /api/v1/guardian/dashboard
|
' GET /api/v1/guardian/dashboard
|
||||||
}
|
}
|
||||||
|
|
||||||
WalkGuideCubitFacade --> VoiceCommandHandler : processText()
|
WalkGuideBlocFacade --> VoiceCommandHandler : processText()
|
||||||
VoiceCommandHandler --> SttServiceFacade : delegates
|
VoiceCommandHandler --> SttServiceFacade : delegates
|
||||||
VoiceCommandHandler --> TtsServiceFacade : delegates
|
VoiceCommandHandler --> TtsServiceFacade : delegates
|
||||||
VoiceCommandHandler --> GoRouterFacade : delegates
|
VoiceCommandHandler --> GoRouterFacade : delegates
|
||||||
VoiceCommandHandler --> CommandActionFacade : delegates
|
VoiceCommandHandler --> SosBlocFacade : delegates
|
||||||
|
|
||||||
GuardianDashboardController --> GuardianDashboardService : getDashboard()
|
GuardianDashboardController --> GuardianDashboardService : getDashboard()
|
||||||
GuardianDashboardService --> LocationService : aggregates
|
GuardianDashboardService --> LocationService : aggregates
|
||||||
|
|||||||
@ -57,8 +57,8 @@ package "④ Repository Pattern [Structural — Proxy]" #FFF3E0 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class "WalkGuideRepositoryImpl\n<<Proxy>>" as WalkGuideRepoImpl {
|
class "WalkGuideRepositoryImpl\n<<Proxy>>" as WalkGuideRepoImpl {
|
||||||
- _apiClient : ApiClient
|
- _remoteDataSource : WalkGuideRemoteDataSource
|
||||||
- _offlineQueue : OfflineQueueService
|
- _localDataSource : WalkGuideLocalDataSource
|
||||||
- _connectivity : ConnectivityPlus
|
- _connectivity : ConnectivityPlus
|
||||||
+ startSession() : Either<Failure, void>
|
+ startSession() : Either<Failure, void>
|
||||||
+ logObstacle(req) : Either<Failure, void>
|
+ logObstacle(req) : Either<Failure, void>
|
||||||
@ -72,16 +72,16 @@ package "④ Repository Pattern [Structural — Proxy]" #FFF3E0 {
|
|||||||
+ syncPending() : Either<Failure, void>
|
+ syncPending() : Either<Failure, void>
|
||||||
}
|
}
|
||||||
|
|
||||||
class "ApiClient\n<<Remote>>" as RemoteDSWalk {
|
class "WalkGuideRemoteDataSource\n<<Remote>>" as RemoteDSWalk {
|
||||||
+ startSession() : void
|
+ startSession() : void
|
||||||
+ logObstacle(req) : void
|
+ logObstacle(req) : void
|
||||||
' POST /api/v1/user/obstacle
|
' POST /api/v1/user/obstacle
|
||||||
}
|
}
|
||||||
|
|
||||||
class "OfflineQueueService + LocalDatabase\n<<SQLite Cache>>" as LocalDSWalk {
|
class "WalkGuideLocalDataSource\n<<SQLite/Drift>>" as LocalDSWalk {
|
||||||
+ cacheObstacle(ObstacleLog) : void
|
+ cacheObstacle(ObstacleLog) : void
|
||||||
+ getPendingLogs() : List<ObstacleLog>
|
+ getPendingLogs() : List<ObstacleLog>
|
||||||
' SQLite-backed offline first
|
' Drift ORM — offline first
|
||||||
}
|
}
|
||||||
|
|
||||||
WalkGuideRepo <|.. WalkGuideRepoImpl : implements
|
WalkGuideRepo <|.. WalkGuideRepoImpl : implements
|
||||||
|
|||||||
@ -43,27 +43,30 @@ skinparam note {
|
|||||||
|
|
||||||
package "⑤ Observer Pattern [Behavioral]" #F3E5F5 {
|
package "⑤ Observer Pattern [Behavioral]" #F3E5F5 {
|
||||||
|
|
||||||
abstract class "Cubit<State>\n<<Subject>>" as BlocSubject {
|
abstract class "Bloc<Event, State>\n<<Subject>>" as BlocSubject {
|
||||||
# stateController : StreamController<State>
|
# stateController : StreamController<State>
|
||||||
|
+ {abstract} on<E>(EventHandler)
|
||||||
|
+ add(Event event)
|
||||||
+ emit(State state)
|
+ emit(State state)
|
||||||
+ stream : Stream<State>
|
+ stream : Stream<State>
|
||||||
}
|
}
|
||||||
|
|
||||||
class "WalkGuideCubit\n<<ConcreteSubject>>" as WalkGuideCubitObs {
|
class "WalkGuideBloc\n<<ConcreteSubject>>" as WalkGuideBlocObs {
|
||||||
+ start()
|
+ on<StartWalkGuide>(_onStart)
|
||||||
+ stop()
|
+ on<StopWalkGuide>(_onStop)
|
||||||
+ logObstacle()
|
+ on<CameraFrameReceived>(_onFrame)
|
||||||
|
+ on<ObstacleDetected>(_onObstacle)
|
||||||
- _yoloDetector : YoloDetector
|
- _yoloDetector : YoloDetector
|
||||||
- _ttsService : TtsService
|
- _ttsService : TtsService
|
||||||
- _hapticService : HapticService
|
- _hapticService : HapticService
|
||||||
}
|
}
|
||||||
|
|
||||||
class "BlocBuilder<WalkGuideCubit, WalkGuideState>\n<<Observer / Widget>>" as BlocBuilderWidget {
|
class "BlocBuilder<WalkGuideBloc, WalkGuideState>\n<<Observer / Widget>>" as BlocBuilderWidget {
|
||||||
+ builder(ctx, state) : Widget
|
+ builder(ctx, state) : Widget
|
||||||
' Rebuilds UI on every state emission
|
' Rebuilds UI on every state emission
|
||||||
}
|
}
|
||||||
|
|
||||||
class "BlocListener<WalkGuideCubit, WalkGuideState>\n<<Observer / SideEffect>>" as BlocListenerWidget {
|
class "BlocListener<WalkGuideBloc, WalkGuideState>\n<<Observer / SideEffect>>" as BlocListenerWidget {
|
||||||
+ listener(ctx, state) : void
|
+ listener(ctx, state) : void
|
||||||
' Side effects: TTS, haptic, navigation
|
' Side effects: TTS, haptic, navigation
|
||||||
}
|
}
|
||||||
@ -81,9 +84,9 @@ package "⑤ Observer Pattern [Behavioral]" #F3E5F5 {
|
|||||||
' Updates flutter_map markers in real-time
|
' Updates flutter_map markers in real-time
|
||||||
}
|
}
|
||||||
|
|
||||||
BlocSubject <|-- WalkGuideCubitObs : extends
|
BlocSubject <|-- WalkGuideBlocObs : extends
|
||||||
WalkGuideCubitObs --> BlocBuilderWidget : emits state\n(rebuilds UI)
|
WalkGuideBlocObs --> BlocBuilderWidget : emits state\n(rebuilds UI)
|
||||||
WalkGuideCubitObs --> BlocListenerWidget : emits state\n(side effects)
|
WalkGuideBlocObs --> BlocListenerWidget : emits state\n(side effects)
|
||||||
WebSocketObs --> GuardianMapObs : notifies\nlive location
|
WebSocketObs --> GuardianMapObs : notifies\nlive location
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -104,13 +104,6 @@
|
|||||||
<!-- yang di-copy dari Agora open-source token builder. Tidak perlu library eksternal. -->
|
<!-- yang di-copy dari Agora open-source token builder. Tidak perlu library eksternal. -->
|
||||||
<!-- Referensi: https://github.com/AgoraIO/Tools/tree/master/DynamicKey/AgoraDynamicKey/java -->
|
<!-- Referensi: https://github.com/AgoraIO/Tools/tree/master/DynamicKey/AgoraDynamicKey/java -->
|
||||||
|
|
||||||
<!-- FIREBASE ADMIN SDK: FCM push + Firestore notification audit trail -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.google.firebase</groupId>
|
|
||||||
<artifactId>firebase-admin</artifactId>
|
|
||||||
<version>9.3.0</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- TESTING -->
|
<!-- TESTING -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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,14 +36,11 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||||
// Endpoint WebSocket utama untuk Flutter/stomp_dart_client.
|
// Endpoint WebSocket utama
|
||||||
// Flutter connect ke: ws://host:port/ws (tanpa SockJS)
|
// Flutter connect ke: ws://host:port/ws (tanpa SockJS)
|
||||||
|
// Browser/Postman bisa pakai SockJS fallback: http://host:port/ws
|
||||||
registry.addEndpoint("/ws")
|
registry.addEndpoint("/ws")
|
||||||
.setAllowedOriginPatterns("*"); // Allow semua origin untuk testing HP di LAN
|
.setAllowedOriginPatterns("*") // Allow semua origin untuk testing HP di LAN
|
||||||
|
.withSockJS(); // SockJS fallback untuk browser compatibility
|
||||||
// Endpoint fallback SockJS untuk browser tooling bila dibutuhkan.
|
|
||||||
registry.addEndpoint("/ws-sockjs")
|
|
||||||
.setAllowedOriginPatterns("*")
|
|
||||||
.withSockJS();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,12 +14,9 @@ import jakarta.validation.Valid;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -39,80 +36,35 @@ public class CallController {
|
|||||||
@Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
|
@Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
|
||||||
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
|
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
|
||||||
@Valid @RequestBody CallTokenRequest req) {
|
@Valid @RequestBody CallTokenRequest req) {
|
||||||
|
|
||||||
Long callerId = SecurityHelper.getCurrentUserId();
|
Long callerId = SecurityHelper.getCurrentUserId();
|
||||||
AgoraTokenResponse response = agoraTokenService.generateToken(callerId, req.getReceiverId());
|
AgoraTokenResponse response = agoraTokenService.generateToken(callerId, req.getReceiverId());
|
||||||
|
|
||||||
log.info("[CALL] Token generated | caller={} receiver={} channel={}",
|
log.info("[CALL] Token generated | caller={} receiver={} channel={}",
|
||||||
callerId, req.getReceiverId(), response.getChannelName());
|
callerId, req.getReceiverId(), response.getChannelName());
|
||||||
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
|
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/notify")
|
@PostMapping("/notify")
|
||||||
@Operation(summary = "Notify receiver of incoming call")
|
@Operation(summary = "Notify receiver of incoming call")
|
||||||
public ResponseEntity<ApiResponse<Void>> notifyCall(@Valid @RequestBody CallNotifyRequest req) {
|
public ResponseEntity<ApiResponse<Void>> notifyCall(
|
||||||
|
@Valid @RequestBody CallNotifyRequest req) {
|
||||||
|
|
||||||
Long callerId = SecurityHelper.getCurrentUserId();
|
Long callerId = SecurityHelper.getCurrentUserId();
|
||||||
String message = callNotificationService.notifyIncomingCall(callerId, req);
|
String message = callNotificationService.notifyIncomingCall(callerId, req);
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null, message));
|
return ResponseEntity.ok(ApiResponse.ok(null, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/accept")
|
|
||||||
@Operation(summary = "Receiver accepts incoming call")
|
|
||||||
public ResponseEntity<ApiResponse<Map<String, String>>> acceptCall(@RequestBody Map<String, String> body) {
|
|
||||||
Long receiverId = SecurityHelper.getCurrentUserId();
|
|
||||||
Long callerId = Long.parseLong(body.get("callerId"));
|
|
||||||
String channelName = body.get("channelName");
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(
|
|
||||||
callNotificationService.acceptCall(receiverId, callerId, channelName),
|
|
||||||
"Call accepted"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/pending")
|
|
||||||
@Operation(summary = "Get pending incoming call for logged-in receiver")
|
|
||||||
public ResponseEntity<ApiResponse<Map<String, String>>> pendingCall() {
|
|
||||||
Long receiverId = SecurityHelper.getCurrentUserId();
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getPendingCall(receiverId), "Pending call"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/pending")
|
|
||||||
@Operation(summary = "Clear pending incoming call for logged-in receiver")
|
|
||||||
public ResponseEntity<ApiResponse<Void>> clearPendingCall() {
|
|
||||||
Long receiverId = SecurityHelper.getCurrentUserId();
|
|
||||||
callNotificationService.clearPendingCall(receiverId);
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null, "Pending call cleared"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/accepted")
|
|
||||||
@Operation(summary = "Get accepted call for logged-in caller")
|
|
||||||
public ResponseEntity<ApiResponse<Map<String, String>>> acceptedCall() {
|
|
||||||
Long callerId = SecurityHelper.getCurrentUserId();
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getAcceptedCall(callerId), "Accepted call"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/accepted")
|
|
||||||
@Operation(summary = "Clear accepted call for logged-in caller")
|
|
||||||
public ResponseEntity<ApiResponse<Void>> clearAcceptedCall() {
|
|
||||||
Long callerId = SecurityHelper.getCurrentUserId();
|
|
||||||
callNotificationService.clearAcceptedCall(callerId);
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null, "Accepted call cleared"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/state")
|
|
||||||
@Operation(summary = "Get call state by Agora channel")
|
|
||||||
public ResponseEntity<ApiResponse<Map<String, String>>> callState(@RequestParam String channelName) {
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getCallState(channelName), "Call state"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/end")
|
@PostMapping("/end")
|
||||||
@Operation(summary = "Notify end of call")
|
@Operation(summary = "Notify end of call")
|
||||||
public ResponseEntity<ApiResponse<Void>> endCall(@RequestBody Map<String, String> body) {
|
public ResponseEntity<ApiResponse<Void>> endCall(
|
||||||
|
@RequestBody Map<String, Long> body) {
|
||||||
|
|
||||||
Long callerId = SecurityHelper.getCurrentUserId();
|
Long callerId = SecurityHelper.getCurrentUserId();
|
||||||
Long otherId = Long.parseLong(body.get("otherId"));
|
Long otherId = body.get("otherId");
|
||||||
String channelName = body.get("channelName");
|
|
||||||
if (channelName == null || channelName.isBlank()) {
|
|
||||||
callNotificationService.notifyCallEnded(callerId, otherId);
|
callNotificationService.notifyCallEnded(callerId, otherId);
|
||||||
} else {
|
|
||||||
callNotificationService.notifyCallEnded(callerId, otherId, channelName);
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
|
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,8 +24,6 @@ public class CallNotifyRequest {
|
|||||||
/** Token Agora untuk receiver — dikirim lewat FCM payload */
|
/** Token Agora untuk receiver — dikirim lewat FCM payload */
|
||||||
private String agoraToken;
|
private String agoraToken;
|
||||||
|
|
||||||
private String agoraAppId;
|
|
||||||
|
|
||||||
/** UID Agora untuk receiver */
|
/** UID Agora untuk receiver */
|
||||||
private int receiverUid;
|
private int receiverUid;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,5 +8,4 @@ public class LocationUpdateRequest {
|
|||||||
private Double accuracy;
|
private Double accuracy;
|
||||||
private Double speed;
|
private Double speed;
|
||||||
private Double heading;
|
private Double heading;
|
||||||
private Integer batteryLevel;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@ -23,8 +22,6 @@ public class DashboardResponse {
|
|||||||
// Status
|
// Status
|
||||||
private long unreadSosCount;
|
private long unreadSosCount;
|
||||||
private long unreadNotifCount;
|
private long unreadNotifCount;
|
||||||
private long obstaclesToday;
|
|
||||||
private Map<String, Object> userStatus;
|
|
||||||
|
|
||||||
// Recent activity (5 terbaru)
|
// Recent activity (5 terbaru)
|
||||||
private List<ActivityLogResponse> recentActivity;
|
private List<ActivityLogResponse> recentActivity;
|
||||||
|
|||||||
@ -16,6 +16,5 @@ public class LocationResponse {
|
|||||||
private Double accuracy;
|
private Double accuracy;
|
||||||
private Double speed;
|
private Double speed;
|
||||||
private Double heading;
|
private Double heading;
|
||||||
private Integer batteryLevel;
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,9 +29,6 @@ public class LocationHistory {
|
|||||||
private Double speed; // m/s
|
private Double speed; // m/s
|
||||||
private Double heading; // derajat 0-360
|
private Double heading; // derajat 0-360
|
||||||
|
|
||||||
@Column(name = "battery_level")
|
|
||||||
private Integer batteryLevel;
|
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.walkguide.exception;
|
package com.walkguide.exception;
|
||||||
|
|
||||||
import com.walkguide.dto.ApiResponse;
|
import com.walkguide.dto.ApiResponse;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
@ -30,22 +29,10 @@ public class GlobalExceptionHandler {
|
|||||||
.body(ApiResponse.error("VALIDATION_ERROR", msg));
|
.body(ApiResponse.error("VALIDATION_ERROR", msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
|
||||||
public ResponseEntity<ApiResponse<Object>> handleDataIntegrity(DataIntegrityViolationException ex) {
|
|
||||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
|
||||||
.body(ApiResponse.error("DATA_CONFLICT",
|
|
||||||
"Data pairing lama masih bentrok. Refresh status atau unpair dulu, lalu coba lagi."));
|
|
||||||
}
|
|
||||||
@ExceptionHandler(RuntimeException.class)
|
@ExceptionHandler(RuntimeException.class)
|
||||||
public ResponseEntity<ApiResponse<Object>> handleRuntime(RuntimeException ex) {
|
public ResponseEntity<ApiResponse<Object>> handleRuntime(RuntimeException ex) {
|
||||||
String message = ex.getMessage();
|
|
||||||
if ("Email tidak terdaftar".equals(message) || "Password salah".equals(message)) {
|
|
||||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
|
||||||
.body(ApiResponse.error("AUTH_INVALID", message));
|
|
||||||
}
|
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
.body(ApiResponse.error("INTERNAL_ERROR", message));
|
.body(ApiResponse.error("INTERNAL_ERROR", ex.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
|
|||||||
@ -4,11 +4,7 @@ import org.springframework.data.domain.Page;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface ObstacleLogRepository extends JpaRepository<ObstacleLog, Long> {
|
public interface ObstacleLogRepository extends JpaRepository<ObstacleLog, Long> {
|
||||||
Page<ObstacleLog> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
|
Page<ObstacleLog> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
|
||||||
long countByUserIdAndCreatedAtAfter(Long userId, LocalDateTime createdAt);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,10 +8,7 @@ import io.jsonwebtoken.security.Keys;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.Key;
|
import java.security.Key;
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -85,34 +82,7 @@ public class JwtUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Key getSignInKey() {
|
private Key getSignInKey() {
|
||||||
byte[] keyBytes = decodeSecret(secretKey);
|
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
|
||||||
return Keys.hmacShaKeyFor(keyBytes);
|
return Keys.hmacShaKeyFor(keyBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] decodeSecret(String configuredSecret) {
|
|
||||||
String trimmed = configuredSecret == null ? "" : configuredSecret.trim();
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
throw new IllegalStateException("JWT secret must not be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] keyBytes;
|
|
||||||
try {
|
|
||||||
keyBytes = Decoders.BASE64.decode(trimmed);
|
|
||||||
} catch (RuntimeException base64Error) {
|
|
||||||
try {
|
|
||||||
keyBytes = Decoders.BASE64URL.decode(trimmed);
|
|
||||||
} catch (RuntimeException base64UrlError) {
|
|
||||||
keyBytes = trimmed.getBytes(StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyBytes.length >= 32) {
|
|
||||||
return keyBytes;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return MessageDigest.getInstance("SHA-256").digest(keyBytes);
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new IllegalStateException("SHA-256 is not available", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,14 +4,11 @@ import com.walkguide.dto.request.CallNotifyRequest;
|
|||||||
import com.walkguide.entity.User;
|
import com.walkguide.entity.User;
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
import com.walkguide.repository.UserRepository;
|
import com.walkguide.repository.UserRepository;
|
||||||
import com.walkguide.websocket.LocationBroadcaster;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -20,39 +17,29 @@ public class CallNotificationService {
|
|||||||
|
|
||||||
private final FcmService fcmService;
|
private final FcmService fcmService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final LocationBroadcaster locationBroadcaster;
|
|
||||||
private final Map<Long, Map<String, String>> pendingCalls = new ConcurrentHashMap<>();
|
|
||||||
private final Map<Long, Map<String, String>> acceptedCalls = new ConcurrentHashMap<>();
|
|
||||||
private final Map<String, Map<String, String>> callStates = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public String notifyIncomingCall(Long callerId, CallNotifyRequest req) {
|
public String notifyIncomingCall(Long callerId, CallNotifyRequest req) {
|
||||||
User caller = userRepository.findById(callerId)
|
User caller = userRepository.findById(callerId)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
|
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
|
||||||
|
|
||||||
User receiver = userRepository.findById(req.getReceiverId())
|
User receiver = userRepository.findById(req.getReceiverId())
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
|
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
|
||||||
|
|
||||||
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
|
|
||||||
Map<String, String> payload = new HashMap<>();
|
|
||||||
payload.put("type", "INCOMING_CALL");
|
|
||||||
payload.put("status", "RINGING");
|
|
||||||
payload.put("callerId", String.valueOf(callerId));
|
|
||||||
payload.put("receiverId", String.valueOf(receiver.getId()));
|
|
||||||
payload.put("callerName", callerName);
|
|
||||||
payload.put("channelName", req.getChannelName());
|
|
||||||
payload.put("agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "");
|
|
||||||
payload.put("agoraAppId", req.getAgoraAppId() != null ? req.getAgoraAppId() : "");
|
|
||||||
payload.put("receiverUid", String.valueOf(req.getReceiverUid()));
|
|
||||||
|
|
||||||
pendingCalls.put(receiver.getId(), payload);
|
|
||||||
acceptedCalls.remove(callerId);
|
|
||||||
callStates.put(req.getChannelName(), payload);
|
|
||||||
locationBroadcaster.broadcastCall(receiver.getId(), payload);
|
|
||||||
|
|
||||||
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
|
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
|
||||||
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId());
|
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId());
|
||||||
return "Panggilan dikirim via realtime fallback.";
|
return "Panggilan dikirim (receiver mungkin tidak menerima push notification)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
|
||||||
|
Map<String, String> payload = Map.of(
|
||||||
|
"type", "INCOMING_CALL",
|
||||||
|
"callerId", String.valueOf(callerId),
|
||||||
|
"callerName", callerName,
|
||||||
|
"channelName", req.getChannelName(),
|
||||||
|
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
|
||||||
|
"receiverUid", String.valueOf(req.getReceiverUid())
|
||||||
|
);
|
||||||
|
|
||||||
fcmService.sendHighPriority(
|
fcmService.sendHighPriority(
|
||||||
receiver.getFcmToken(),
|
receiver.getFcmToken(),
|
||||||
"Panggilan Masuk",
|
"Panggilan Masuk",
|
||||||
@ -65,111 +52,22 @@ public class CallNotificationService {
|
|||||||
return "Notifikasi panggilan berhasil dikirim";
|
return "Notifikasi panggilan berhasil dikirim";
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, String> acceptCall(Long receiverId, Long callerId, String channelName) {
|
|
||||||
User receiver = userRepository.findById(receiverId)
|
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
|
|
||||||
userRepository.findById(callerId)
|
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
|
|
||||||
|
|
||||||
pendingCalls.remove(receiverId);
|
|
||||||
String receiverName = receiver.getDisplayName() != null ? receiver.getDisplayName() : receiver.getEmail();
|
|
||||||
Map<String, String> payload = new HashMap<>(getCallState(channelName));
|
|
||||||
payload.put("type", "CALL_ACCEPTED");
|
|
||||||
payload.put("status", "ACCEPTED");
|
|
||||||
payload.put("callerId", String.valueOf(callerId));
|
|
||||||
payload.put("receiverId", String.valueOf(receiverId));
|
|
||||||
payload.put("receiverName", receiverName);
|
|
||||||
payload.put("channelName", channelName != null ? channelName : "");
|
|
||||||
payload.put("acceptedBy", String.valueOf(receiverId));
|
|
||||||
payload.put("acceptedAt", String.valueOf(System.currentTimeMillis()));
|
|
||||||
|
|
||||||
acceptedCalls.put(callerId, payload);
|
|
||||||
if (channelName != null && !channelName.isBlank()) {
|
|
||||||
callStates.put(channelName, payload);
|
|
||||||
}
|
|
||||||
locationBroadcaster.broadcastCall(callerId, payload);
|
|
||||||
locationBroadcaster.broadcastCall(receiverId, payload);
|
|
||||||
log.info("[CALL] Call accepted | caller={} receiver={} channel={}", callerId, receiverId, channelName);
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, String> getPendingCall(Long receiverId) {
|
|
||||||
return pendingCalls.get(receiverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void clearPendingCall(Long receiverId) {
|
|
||||||
pendingCalls.remove(receiverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, String> getAcceptedCall(Long callerId) {
|
|
||||||
return acceptedCalls.get(callerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void clearAcceptedCall(Long callerId) {
|
|
||||||
acceptedCalls.remove(callerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, String> getCallState(String channelName) {
|
|
||||||
if (channelName == null || channelName.isBlank()) {
|
|
||||||
return new HashMap<>();
|
|
||||||
}
|
|
||||||
return callStates.getOrDefault(channelName, new HashMap<>());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void notifyCallEnded(Long callerId, Long otherId) {
|
public void notifyCallEnded(Long callerId, Long otherId) {
|
||||||
notifyCallEnded(callerId, otherId, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void notifyCallEnded(Long callerId, Long otherId, String channelName) {
|
|
||||||
if (otherId == null) {
|
if (otherId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPendingCall(otherId);
|
|
||||||
clearPendingCall(callerId);
|
|
||||||
clearAcceptedCall(callerId);
|
|
||||||
clearAcceptedCall(otherId);
|
|
||||||
|
|
||||||
String resolvedChannel = channelName;
|
|
||||||
if (resolvedChannel == null || resolvedChannel.isBlank()) {
|
|
||||||
resolvedChannel = findActiveChannel(callerId, otherId);
|
|
||||||
}
|
|
||||||
Map<String, String> payload = new HashMap<>(getCallState(resolvedChannel));
|
|
||||||
payload.put("type", "CALL_ENDED");
|
|
||||||
payload.put("status", "ENDED");
|
|
||||||
payload.put("callerId", String.valueOf(callerId));
|
|
||||||
payload.put("otherId", String.valueOf(otherId));
|
|
||||||
payload.put("channelName", resolvedChannel != null ? resolvedChannel : "");
|
|
||||||
payload.put("endedBy", String.valueOf(callerId));
|
|
||||||
payload.put("endedAt", String.valueOf(System.currentTimeMillis()));
|
|
||||||
if (resolvedChannel != null && !resolvedChannel.isBlank()) {
|
|
||||||
callStates.put(resolvedChannel, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
locationBroadcaster.broadcastCall(otherId, payload);
|
|
||||||
locationBroadcaster.broadcastCall(callerId, payload);
|
|
||||||
|
|
||||||
userRepository.findById(otherId).ifPresent(other -> {
|
userRepository.findById(otherId).ifPresent(other -> {
|
||||||
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
|
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fcmService.sendToToken(
|
fcmService.sendToToken(
|
||||||
other.getFcmToken(),
|
other.getFcmToken(),
|
||||||
"Panggilan Berakhir",
|
"Panggilan Berakhir",
|
||||||
"Panggilan telah berakhir",
|
"Panggilan telah berakhir",
|
||||||
payload
|
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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,130 +1,50 @@
|
|||||||
package com.walkguide.service;
|
package com.walkguide.service;
|
||||||
|
|
||||||
import com.google.cloud.Timestamp;
|
import lombok.RequiredArgsConstructor;
|
||||||
import com.google.cloud.firestore.Firestore;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import com.google.firebase.FirebaseApp;
|
|
||||||
import com.google.firebase.cloud.FirestoreClient;
|
|
||||||
import com.google.firebase.messaging.AndroidConfig;
|
|
||||||
import com.google.firebase.messaging.AndroidNotification;
|
|
||||||
import com.google.firebase.messaging.FirebaseMessaging;
|
|
||||||
import com.google.firebase.messaging.Message;
|
|
||||||
import com.google.firebase.messaging.Notification;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FCM Service untuk push notification dan audit notifikasi ke Firestore.
|
* FCM Service untuk push notification.
|
||||||
* Jika Firebase credential belum tersedia, service tetap aman berjalan dalam mode log-only.
|
* 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.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class FcmService {
|
public class FcmService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(FcmService.class);
|
|
||||||
|
|
||||||
public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) {
|
public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) {
|
||||||
sendInternal(fcmToken, title, body, data, false);
|
if (fcmToken == null || fcmToken.isBlank()) {
|
||||||
|
log.warn("[FCM] Token kosong, skip notifikasi: {} - {}", title, body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// LOG ONLY untuk sekarang
|
||||||
|
log.info("[FCM] TO={} | TITLE={} | BODY={} | DATA={}", fcmToken, title, body, data);
|
||||||
|
|
||||||
|
// TODO: uncomment ini setelah tambah Firebase Admin SDK ke pom.xml
|
||||||
|
// 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 sendHighPriority(String fcmToken, String title, String body, Map<String, String> data) {
|
public void sendHighPriority(String fcmToken, String title, String body, Map<String, String> data) {
|
||||||
sendInternal(fcmToken, title, body, data, true);
|
// SOS dan incoming call pakai ini - sama untuk sekarang
|
||||||
}
|
sendToToken(fcmToken, title, body, data);
|
||||||
|
|
||||||
@Value("${firebase.notifications-collection:notifications}")
|
|
||||||
private String notificationsCollection;
|
|
||||||
|
|
||||||
private void sendInternal(String fcmToken, String title, String body, Map<String, String> data, boolean highPriority) {
|
|
||||||
Map<String, String> safeData = data != null ? data : Map.of();
|
|
||||||
String status = "SKIPPED";
|
|
||||||
String messageId = null;
|
|
||||||
|
|
||||||
if (fcmToken == null || fcmToken.isBlank()) {
|
|
||||||
log.warn("[FCM] Token kosong, skip notifikasi: {} - {}", title, body);
|
|
||||||
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (FirebaseApp.getApps().isEmpty()) {
|
|
||||||
status = "LOG_ONLY";
|
|
||||||
log.info("[FCM] LOG_ONLY TO={} | TITLE={} | BODY={} | DATA={}",
|
|
||||||
maskToken(fcmToken), title, body, safeData);
|
|
||||||
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
AndroidConfig.Priority priority = highPriority
|
|
||||||
? AndroidConfig.Priority.HIGH
|
|
||||||
: AndroidConfig.Priority.NORMAL;
|
|
||||||
|
|
||||||
AndroidNotification androidNotification = AndroidNotification.builder()
|
|
||||||
.setChannelId(highPriority ? "walkguide_urgent" : "walkguide_alerts")
|
|
||||||
.setPriority(highPriority
|
|
||||||
? AndroidNotification.Priority.MAX
|
|
||||||
: AndroidNotification.Priority.DEFAULT)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
Message message = Message.builder()
|
|
||||||
.setToken(fcmToken)
|
|
||||||
.setNotification(Notification.builder()
|
|
||||||
.setTitle(title != null ? title : "WalkGuide")
|
|
||||||
.setBody(body != null ? body : "")
|
|
||||||
.build())
|
|
||||||
.putAllData(safeData)
|
|
||||||
.setAndroidConfig(AndroidConfig.builder()
|
|
||||||
.setPriority(priority)
|
|
||||||
.setNotification(androidNotification)
|
|
||||||
.build())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
messageId = FirebaseMessaging.getInstance().send(message);
|
|
||||||
status = "SENT";
|
|
||||||
log.info("[FCM] Sent {} notification successfully: {}", highPriority ? "high-priority" : "normal", messageId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
status = "FAILED";
|
|
||||||
log.error("[FCM] Failed to send notification: {}", e.getMessage());
|
|
||||||
} finally {
|
|
||||||
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, messageId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveNotificationAudit(String fcmToken, String title, String body, Map<String, String> data,
|
|
||||||
boolean highPriority, String status, String messageId) {
|
|
||||||
if (FirebaseApp.getApps().isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Firestore firestore = FirestoreClient.getFirestore();
|
|
||||||
Map<String, Object> doc = new HashMap<>();
|
|
||||||
doc.put("title", title);
|
|
||||||
doc.put("body", body);
|
|
||||||
doc.put("type", data.getOrDefault("type", "GENERAL"));
|
|
||||||
doc.put("data", data);
|
|
||||||
doc.put("priority", highPriority ? "HIGH" : "NORMAL");
|
|
||||||
doc.put("status", status);
|
|
||||||
doc.put("messageId", messageId);
|
|
||||||
doc.put("recipientTokenMasked", maskToken(fcmToken));
|
|
||||||
doc.put("createdAt", Timestamp.ofTimeSecondsAndNanos(Instant.now().getEpochSecond(), 0));
|
|
||||||
|
|
||||||
firestore.collection(notificationsCollection).add(doc).get();
|
|
||||||
log.debug("[FIRESTORE] Notification audit saved | type={} status={}", doc.get("type"), status);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[FIRESTORE] Notification audit skipped: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String maskToken(String token) {
|
|
||||||
if (token == null || token.isBlank()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
int visible = Math.min(6, token.length());
|
|
||||||
return "***" + token.substring(token.length() - visible);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -8,11 +8,6 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.LocalTime;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class GuardianDashboardService {
|
public class GuardianDashboardService {
|
||||||
@ -22,7 +17,6 @@ public class GuardianDashboardService {
|
|||||||
private final ActivityLogService activityLogService;
|
private final ActivityLogService activityLogService;
|
||||||
private final SosEventRepository sosEventRepository;
|
private final SosEventRepository sosEventRepository;
|
||||||
private final GuardianNotificationRepository notifRepository;
|
private final GuardianNotificationRepository notifRepository;
|
||||||
private final ObstacleLogRepository obstacleLogRepository;
|
|
||||||
|
|
||||||
public DashboardResponse getDashboard(Long guardianId) {
|
public DashboardResponse getDashboard(Long guardianId) {
|
||||||
var pairing = pairingRelationRepository
|
var pairing = pairingRelationRepository
|
||||||
@ -46,21 +40,6 @@ public class GuardianDashboardService {
|
|||||||
long unreadSos = sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, 100))
|
long unreadSos = sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, 100))
|
||||||
.stream().filter(s -> s.getStatus().name().equals("TRIGGERED")).count();
|
.stream().filter(s -> s.getStatus().name().equals("TRIGGERED")).count();
|
||||||
long unreadNotif = notifRepository.countByUserIdAndIsReadFalse(userId);
|
long unreadNotif = notifRepository.countByUserIdAndIsReadFalse(userId);
|
||||||
long obstaclesToday = obstacleLogRepository.countByUserIdAndCreatedAtAfter(
|
|
||||||
userId,
|
|
||||||
LocalDateTime.of(LocalDateTime.now().toLocalDate(), LocalTime.MIN)
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object> userStatus = new HashMap<>();
|
|
||||||
userStatus.put("displayName", user.getDisplayName());
|
|
||||||
userStatus.put("email", user.getEmail());
|
|
||||||
userStatus.put("online", lastLocation != null
|
|
||||||
&& lastLocation.getCreatedAt() != null
|
|
||||||
&& lastLocation.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(2)));
|
|
||||||
userStatus.put("lastSeenAt", lastLocation != null ? lastLocation.getCreatedAt() : null);
|
|
||||||
userStatus.put("battery", lastLocation != null ? lastLocation.getBatteryLevel() : null);
|
|
||||||
userStatus.put("lastSpeed", lastLocation != null ? lastLocation.getSpeed() : null);
|
|
||||||
userStatus.put("obstaclesToday", obstaclesToday);
|
|
||||||
|
|
||||||
return DashboardResponse.builder()
|
return DashboardResponse.builder()
|
||||||
.pairedUserId(userId)
|
.pairedUserId(userId)
|
||||||
@ -70,8 +49,6 @@ public class GuardianDashboardService {
|
|||||||
.lastLocation(lastLocation)
|
.lastLocation(lastLocation)
|
||||||
.unreadSosCount(unreadSos)
|
.unreadSosCount(unreadSos)
|
||||||
.unreadNotifCount(unreadNotif)
|
.unreadNotifCount(unreadNotif)
|
||||||
.obstaclesToday(obstaclesToday)
|
|
||||||
.userStatus(userStatus)
|
|
||||||
.recentActivity(recentActivity)
|
.recentActivity(recentActivity)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,6 @@ public class LocationService {
|
|||||||
.accuracy(req.getAccuracy())
|
.accuracy(req.getAccuracy())
|
||||||
.speed(req.getSpeed())
|
.speed(req.getSpeed())
|
||||||
.heading(req.getHeading())
|
.heading(req.getHeading())
|
||||||
.batteryLevel(req.getBatteryLevel())
|
|
||||||
.build();
|
.build();
|
||||||
loc = locationHistoryRepository.save(loc);
|
loc = locationHistoryRepository.save(loc);
|
||||||
|
|
||||||
@ -137,7 +136,6 @@ public class LocationService {
|
|||||||
return LocationResponse.builder()
|
return LocationResponse.builder()
|
||||||
.id(l.getId()).lat(l.getLat()).lng(l.getLng())
|
.id(l.getId()).lat(l.getLat()).lng(l.getLng())
|
||||||
.accuracy(l.getAccuracy()).speed(l.getSpeed()).heading(l.getHeading())
|
.accuracy(l.getAccuracy()).speed(l.getSpeed()).heading(l.getHeading())
|
||||||
.batteryLevel(l.getBatteryLevel())
|
|
||||||
.createdAt(l.getCreatedAt()).build();
|
.createdAt(l.getCreatedAt()).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import com.walkguide.enums.*;
|
|||||||
import com.walkguide.exception.PairingException;
|
import com.walkguide.exception.PairingException;
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
import com.walkguide.repository.*;
|
import com.walkguide.repository.*;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class PairingService {
|
public class PairingService {
|
||||||
|
|
||||||
private final PairingRelationRepository pairingRelationRepository;
|
private final PairingRelationRepository pairingRelationRepository;
|
||||||
@ -32,22 +34,6 @@ public class PairingService {
|
|||||||
private static final int PAIRING_CODE_TTL_MINUTES = 15;
|
private static final int PAIRING_CODE_TTL_MINUTES = 15;
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
public PairingService(PairingRelationRepository pairingRelationRepository,
|
|
||||||
UserRepository userRepository,
|
|
||||||
VoiceCommandConfigRepository voiceCommandConfigRepository,
|
|
||||||
HardwareShortcutRepository hardwareShortcutRepository,
|
|
||||||
AiConfigRepository aiConfigRepository,
|
|
||||||
ActivityLogService activityLogService,
|
|
||||||
FcmService fcmService) {
|
|
||||||
this.pairingRelationRepository = pairingRelationRepository;
|
|
||||||
this.userRepository = userRepository;
|
|
||||||
this.voiceCommandConfigRepository = voiceCommandConfigRepository;
|
|
||||||
this.hardwareShortcutRepository = hardwareShortcutRepository;
|
|
||||||
this.aiConfigRepository = aiConfigRepository;
|
|
||||||
this.activityLogService = activityLogService;
|
|
||||||
this.fcmService = fcmService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public PairingCodeResponse getOrCreatePairingCode(Long userId) {
|
public PairingCodeResponse getOrCreatePairingCode(Long userId) {
|
||||||
User user = userRepository.findById(userId)
|
User user = userRepository.findById(userId)
|
||||||
@ -83,6 +69,7 @@ public class PairingService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
|
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
|
||||||
|
// Guardian tidak boleh punya pairing ACTIVE atau PENDING
|
||||||
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
|
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
|
||||||
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
|
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
|
||||||
}
|
}
|
||||||
@ -101,52 +88,6 @@ public class PairingService {
|
|||||||
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
|
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingGuardianPairing = pairingRelationRepository.findByGuardian_Id(guardianId);
|
|
||||||
if (existingGuardianPairing.isPresent()) {
|
|
||||||
PairingRelation existing = existingGuardianPairing.get();
|
|
||||||
if (existing.getStatus() == PairingStatus.ACTIVE) {
|
|
||||||
if (existing.getUser().getId().equals(user.getId())) {
|
|
||||||
return buildStatus(existing, guardian, existing.getUser(), "GUARDIAN");
|
|
||||||
}
|
|
||||||
throw new PairingException(
|
|
||||||
"Guardian sudah pairing aktif dengan User lain. Unpair dulu sebelum invite User baru.");
|
|
||||||
}
|
|
||||||
if (existing.getStatus() == PairingStatus.PENDING) {
|
|
||||||
if (existing.getUser().getId().equals(user.getId())) {
|
|
||||||
sendPairingInviteNotification(existing, guardian, user);
|
|
||||||
return buildStatus(existing, guardian, user, "GUARDIAN");
|
|
||||||
}
|
|
||||||
throw new PairingException(
|
|
||||||
"Guardian masih punya undangan pairing yang menunggu respons User.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingUserPairing = pairingRelationRepository.findByUser_Id(user.getId());
|
|
||||||
if (existingUserPairing.isPresent()) {
|
|
||||||
PairingRelation existing = existingUserPairing.get();
|
|
||||||
if (existing.getStatus() == PairingStatus.ACTIVE) {
|
|
||||||
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
|
|
||||||
}
|
|
||||||
if (existing.getStatus() == PairingStatus.PENDING) {
|
|
||||||
if (existing.getGuardian().getId().equals(guardianId)) {
|
|
||||||
sendPairingInviteNotification(existing, guardian, user);
|
|
||||||
return buildStatus(existing, guardian, user, "GUARDIAN");
|
|
||||||
}
|
|
||||||
throw new PairingException("User ini masih punya undangan pairing dari Guardian lain.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingGuardianPairing.isPresent()) {
|
|
||||||
pairingRelationRepository.delete(existingGuardianPairing.get());
|
|
||||||
pairingRelationRepository.flush();
|
|
||||||
}
|
|
||||||
if (existingUserPairing.isPresent()
|
|
||||||
&& (existingGuardianPairing.isEmpty()
|
|
||||||
|| !existingUserPairing.get().getId().equals(existingGuardianPairing.get().getId()))) {
|
|
||||||
pairingRelationRepository.delete(existingUserPairing.get());
|
|
||||||
pairingRelationRepository.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
PairingRelation pairing = PairingRelation.builder()
|
PairingRelation pairing = PairingRelation.builder()
|
||||||
.guardian(guardian)
|
.guardian(guardian)
|
||||||
.user(user)
|
.user(user)
|
||||||
@ -158,7 +99,11 @@ public class PairingService {
|
|||||||
user.setPairingCodeExpiresAt(null);
|
user.setPairingCodeExpiresAt(null);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
sendPairingInviteNotification(pairing, guardian, 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()));
|
||||||
|
|
||||||
activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT,
|
activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT,
|
||||||
"Guardian mengirim invite ke " + user.getDisplayName(), null);
|
"Guardian mengirim invite ke " + user.getDisplayName(), null);
|
||||||
@ -250,13 +195,6 @@ public class PairingService {
|
|||||||
// ========== PRIVATE ==========
|
// ========== PRIVATE ==========
|
||||||
|
|
||||||
private void seedDefaults(Long guardianId, Long userId) {
|
private void seedDefaults(Long guardianId, Long userId) {
|
||||||
voiceCommandConfigRepository.deleteByUserId(userId);
|
|
||||||
hardwareShortcutRepository.deleteByUserId(userId);
|
|
||||||
aiConfigRepository.findByUserId(userId).ifPresent(aiConfigRepository::delete);
|
|
||||||
voiceCommandConfigRepository.flush();
|
|
||||||
hardwareShortcutRepository.flush();
|
|
||||||
aiConfigRepository.flush();
|
|
||||||
|
|
||||||
// Voice commands default
|
// Voice commands default
|
||||||
List<VoiceCommandConfig> defaults = List.of(
|
List<VoiceCommandConfig> defaults = List.of(
|
||||||
vc(guardianId, userId, VoiceCommandKey.OPEN_WALKGUIDE, "Open Walkguide"),
|
vc(guardianId, userId, VoiceCommandKey.OPEN_WALKGUIDE, "Open Walkguide"),
|
||||||
@ -323,15 +261,6 @@ public class PairingService {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendPairingInviteNotification(PairingRelation pairing, User guardian, User user) {
|
|
||||||
fcmService.sendToToken(user.getFcmToken(),
|
|
||||||
"Pairing Request",
|
|
||||||
"Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung",
|
|
||||||
Map.of(
|
|
||||||
"type", "PAIRING_INVITE",
|
|
||||||
"pairingId", pairing.getId().toString(),
|
|
||||||
"guardianName", guardian.getDisplayName()));
|
|
||||||
}
|
|
||||||
private void assignNewPairingCode(User user, LocalDateTime now) {
|
private void assignNewPairingCode(User user, LocalDateTime now) {
|
||||||
String candidate;
|
String candidate;
|
||||||
do {
|
do {
|
||||||
@ -378,4 +307,3 @@ public class PairingService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import com.walkguide.entity.User;
|
|||||||
import com.walkguide.enums.ActivityLogType;
|
import com.walkguide.enums.ActivityLogType;
|
||||||
import com.walkguide.enums.PairingStatus;
|
import com.walkguide.enums.PairingStatus;
|
||||||
import com.walkguide.enums.SosStatus;
|
import com.walkguide.enums.SosStatus;
|
||||||
import com.walkguide.exception.PairingException;
|
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
import com.walkguide.repository.*;
|
import com.walkguide.repository.*;
|
||||||
import com.walkguide.websocket.LocationBroadcaster;
|
import com.walkguide.websocket.LocationBroadcaster;
|
||||||
@ -37,14 +36,6 @@ public class SosService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public SosEventResponse triggerSos(Long userId, SosRequest req) {
|
public SosEventResponse triggerSos(Long userId, SosRequest req) {
|
||||||
User user = userRepository.findById(userId)
|
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
|
|
||||||
|
|
||||||
var activePairing = pairingRelationRepository
|
|
||||||
.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
|
|
||||||
.orElseThrow(() -> new PairingException(
|
|
||||||
"SOS hanya bisa dikirim setelah User terhubung dengan Guardian aktif."));
|
|
||||||
|
|
||||||
SosEvent sos = SosEvent.builder()
|
SosEvent sos = SosEvent.builder()
|
||||||
.userId(userId)
|
.userId(userId)
|
||||||
.triggerType(req.getTriggerType() != null ? req.getTriggerType() : "MANUAL")
|
.triggerType(req.getTriggerType() != null ? req.getTriggerType() : "MANUAL")
|
||||||
@ -55,13 +46,18 @@ public class SosService {
|
|||||||
sos = sosEventRepository.save(sos);
|
sos = sosEventRepository.save(sos);
|
||||||
final SosEvent savedSos = sos;
|
final SosEvent savedSos = sos;
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
|
||||||
|
|
||||||
activityLogService.createLog(user, ActivityLogType.SOS_TRIGGERED,
|
activityLogService.createLog(user, ActivityLogType.SOS_TRIGGERED,
|
||||||
"SOS dikirim via " + sos.getTriggerType(), null);
|
"SOS dikirim via " + sos.getTriggerType(), null);
|
||||||
|
|
||||||
SosEventResponse sosResponse = toResponse(savedSos);
|
SosEventResponse sosResponse = toResponse(savedSos);
|
||||||
|
|
||||||
// Kirim ke Guardian via FCM (background) + WebSocket (foreground)
|
// Kirim ke Guardian via FCM (background) + WebSocket (foreground)
|
||||||
User guardian = activePairing.getGuardian();
|
pairingRelationRepository.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
|
||||||
|
.ifPresent(pairing -> {
|
||||||
|
User guardian = pairing.getGuardian();
|
||||||
String guardianFcm = guardian.getFcmToken();
|
String guardianFcm = guardian.getFcmToken();
|
||||||
String locStr = req.getLat() != null
|
String locStr = req.getLat() != null
|
||||||
? String.format("Lat:%.4f,Lng:%.4f", req.getLat(), req.getLng())
|
? String.format("Lat:%.4f,Lng:%.4f", req.getLat(), req.getLng())
|
||||||
@ -82,6 +78,7 @@ public class SosService {
|
|||||||
|
|
||||||
log.info("[SOS] Alert sent to Guardian={} for User={} | trigger={}",
|
log.info("[SOS] Alert sent to Guardian={} for User={} | trigger={}",
|
||||||
guardian.getId(), userId, savedSos.getTriggerType());
|
guardian.getId(), userId, savedSos.getTriggerType());
|
||||||
|
});
|
||||||
|
|
||||||
return sosResponse;
|
return sosResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,49 +3,68 @@ package com.walkguide.websocket;
|
|||||||
import com.walkguide.dto.response.LocationResponse;
|
import com.walkguide.dto.response.LocationResponse;
|
||||||
import com.walkguide.dto.response.NotificationResponse;
|
import com.walkguide.dto.response.NotificationResponse;
|
||||||
import com.walkguide.dto.response.SosEventResponse;
|
import com.walkguide.dto.response.SosEventResponse;
|
||||||
import org.slf4j.Logger;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.slf4j.LoggerFactory;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.Map;
|
/**
|
||||||
|
* Service untuk broadcast pesan real-time via WebSocket (STOMP).
|
||||||
|
*
|
||||||
|
* Dipakai oleh:
|
||||||
|
* - LocationService → broadcast GPS ke Guardian
|
||||||
|
* - SosService → broadcast SOS ke Guardian
|
||||||
|
* - NotificationService→ broadcast notif ke User
|
||||||
|
*
|
||||||
|
* PATTERN: Observer — Guardian/User subscribe ke topic,
|
||||||
|
* LocationBroadcaster push data saat ada update.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class LocationBroadcaster {
|
public class LocationBroadcaster {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(LocationBroadcaster.class);
|
|
||||||
|
|
||||||
private final SimpMessagingTemplate messagingTemplate;
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
|
|
||||||
public LocationBroadcaster(SimpMessagingTemplate messagingTemplate) {
|
/**
|
||||||
this.messagingTemplate = 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 void broadcastLocation(Long userId, LocationResponse location) {
|
public void broadcastLocation(Long userId, LocationResponse location) {
|
||||||
String destination = "/topic/location/" + userId;
|
String destination = "/topic/location/" + userId;
|
||||||
messagingTemplate.convertAndSend(destination, location);
|
messagingTemplate.convertAndSend(destination, location);
|
||||||
log.debug("[WS] Location broadcast -> {} | lat={} lng={}",
|
log.debug("[WS] Location broadcast → {} | lat={} lng={}",
|
||||||
destination, location.getLat(), location.getLng());
|
destination, location.getLat(), location.getLng());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast SOS event ke Guardian secara real-time.
|
||||||
|
* Guardian Flutter subscribe ke: /queue/sos/{guardianId}
|
||||||
|
*
|
||||||
|
* @param guardianId ID dari ROLE_GUARDIAN
|
||||||
|
* @param sos SOS event yang baru di-trigger
|
||||||
|
*/
|
||||||
public void broadcastSos(Long guardianId, SosEventResponse sos) {
|
public void broadcastSos(Long guardianId, SosEventResponse sos) {
|
||||||
String destination = "/queue/sos/" + guardianId;
|
String destination = "/queue/sos/" + guardianId;
|
||||||
messagingTemplate.convertAndSend(destination, sos);
|
messagingTemplate.convertAndSend(destination, sos);
|
||||||
log.info("[WS] SOS broadcast -> {} | userId={} status={}",
|
log.info("[WS] SOS broadcast → {} | userId={} status={}",
|
||||||
destination, sos.getUserId(), sos.getStatus());
|
destination, sos.getUserId(), sos.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast notifikasi dari Guardian ke User secara real-time.
|
||||||
|
* User Flutter subscribe ke: /queue/notif/{userId}
|
||||||
|
*
|
||||||
|
* @param userId ID dari ROLE_USER yang menerima notif
|
||||||
|
* @param notification Notifikasi yang baru dikirim Guardian
|
||||||
|
*/
|
||||||
public void broadcastNotification(Long userId, NotificationResponse notification) {
|
public void broadcastNotification(Long userId, NotificationResponse notification) {
|
||||||
String destination = "/queue/notif/" + userId;
|
String destination = "/queue/notif/" + userId;
|
||||||
messagingTemplate.convertAndSend(destination, notification);
|
messagingTemplate.convertAndSend(destination, notification);
|
||||||
log.debug("[WS] Notification broadcast -> {} | type={}",
|
log.debug("[WS] Notification broadcast → {} | type={}",
|
||||||
destination, notification.getNotifType());
|
destination, notification.getNotifType());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void broadcastCall(Long receiverId, Map<String, String> payload) {
|
|
||||||
String destination = "/queue/call/" + receiverId;
|
|
||||||
messagingTemplate.convertAndSend(destination, payload);
|
|
||||||
log.info("[WS] Call broadcast -> {} | type={} status={} channel={}",
|
|
||||||
destination, payload.get("type"), payload.get("status"), payload.get("channelName"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,35 @@
|
|||||||
DB_URL=jdbc:postgresql://<host>:<port>/<database>
|
# ===================================================
|
||||||
DB_USERNAME=<database_username>
|
# Profile: prod (production)
|
||||||
DB_PASSWORD=<database_password>
|
# Aktifkan dengan: --spring.profiles.active=prod
|
||||||
JWT_SECRET=<base64_hs256_secret_at_least_32_bytes>
|
# Semua nilai WAJIB diisi via environment variable
|
||||||
JWT_EXPIRATION=86400000
|
# Tidak ada default value — akan gagal start jika kosong
|
||||||
AGORA_APP_ID=<agora_app_id>
|
# ===================================================
|
||||||
AGORA_APP_CERTIFICATE=<agora_app_certificate>
|
|
||||||
FIREBASE_CREDENTIALS_PATH=classpath:firebase/google-services-admin.json
|
spring:
|
||||||
FIREBASE_NOTIFICATIONS_COLLECTION=notifications
|
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
|
||||||
@ -6,18 +6,9 @@
|
|||||||
|
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
datasource:
|
||||||
url: ${DB_URL}
|
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
||||||
username: ${DB_USERNAME}
|
username: ${DB_USERNAME:5803024001}
|
||||||
password: ${DB_PASSWORD}
|
password: ${DB_PASSWORD:pw5803024001}
|
||||||
hikari:
|
|
||||||
maximum-pool-size: ${DB_POOL_MAX:1}
|
|
||||||
minimum-idle: ${DB_POOL_MIN_IDLE:0}
|
|
||||||
connection-timeout: ${DB_CONNECTION_TIMEOUT:10000}
|
|
||||||
idle-timeout: ${DB_IDLE_TIMEOUT:30000}
|
|
||||||
max-lifetime: ${DB_MAX_LIFETIME:120000}
|
|
||||||
|
|
||||||
flyway:
|
|
||||||
enabled: ${FLYWAY_ENABLED:true}
|
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
show-sql: true
|
show-sql: true
|
||||||
@ -26,7 +17,7 @@ spring:
|
|||||||
format_sql: true
|
format_sql: true
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET}
|
secret: ${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt}
|
||||||
expiration: ${JWT_EXPIRATION:86400000}
|
expiration: ${JWT_EXPIRATION:86400000}
|
||||||
|
|
||||||
agora:
|
agora:
|
||||||
|
|||||||
@ -1,19 +1,11 @@
|
|||||||
# ===== SERVER =====
|
# ===== SERVER =====
|
||||||
spring.config.import=optional:file:./secrets.properties,optional:file:walkguide-backend/demo/secrets.properties
|
|
||||||
server.port=${SERVER_PORT:8080}
|
server.port=${SERVER_PORT:8080}
|
||||||
server.address=${SERVER_ADDRESS:0.0.0.0}
|
|
||||||
|
|
||||||
# ===== POSTGRESQL CONNECTION =====
|
# ===== POSTGRESQL CONNECTION =====
|
||||||
spring.datasource.url=${DB_URL}
|
spring.datasource.url=${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
||||||
spring.datasource.username=${DB_USERNAME}
|
spring.datasource.username=${DB_USERNAME:5803024001}
|
||||||
spring.datasource.password=${DB_PASSWORD}
|
spring.datasource.password=${DB_PASSWORD:pw5803024001}
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
# ===== HIKARI POOL (keep DB classroom slots low) =====
|
|
||||||
spring.datasource.hikari.maximum-pool-size=${DB_POOL_MAX:1}
|
|
||||||
spring.datasource.hikari.minimum-idle=${DB_POOL_MIN_IDLE:0}
|
|
||||||
spring.datasource.hikari.connection-timeout=${DB_CONNECTION_TIMEOUT:10000}
|
|
||||||
spring.datasource.hikari.idle-timeout=${DB_IDLE_TIMEOUT:30000}
|
|
||||||
spring.datasource.hikari.max-lifetime=${DB_MAX_LIFETIME:120000}
|
|
||||||
|
|
||||||
# ===== JPA / HIBERNATE =====
|
# ===== JPA / HIBERNATE =====
|
||||||
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
|
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
|
||||||
@ -27,7 +19,7 @@ spring.flyway.locations=classpath:db/migration
|
|||||||
spring.flyway.baseline-on-migrate=true
|
spring.flyway.baseline-on-migrate=true
|
||||||
|
|
||||||
# ===== JWT =====
|
# ===== JWT =====
|
||||||
jwt.secret=${JWT_SECRET}
|
jwt.secret=${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt}
|
||||||
jwt.expiration=${JWT_EXPIRATION:86400000}
|
jwt.expiration=${JWT_EXPIRATION:86400000}
|
||||||
|
|
||||||
# ===== SWAGGER =====
|
# ===== SWAGGER =====
|
||||||
@ -38,10 +30,6 @@ springdoc.api-docs.path=/v3/api-docs
|
|||||||
agora.app-id=${AGORA_APP_ID:}
|
agora.app-id=${AGORA_APP_ID:}
|
||||||
agora.app-certificate=${AGORA_APP_CERTIFICATE:}
|
agora.app-certificate=${AGORA_APP_CERTIFICATE:}
|
||||||
|
|
||||||
# ===== FIREBASE =====
|
|
||||||
firebase.credentials-path=${FIREBASE_CREDENTIALS_PATH:classpath:firebase/google-services-admin.json}
|
|
||||||
firebase.notifications-collection=${FIREBASE_NOTIFICATIONS_COLLECTION:notifications}
|
|
||||||
|
|
||||||
# ===== WEBSOCKET =====
|
# ===== WEBSOCKET =====
|
||||||
# WebSocket auto-dikonfigurasi oleh WebSocketConfig.java
|
# WebSocket auto-dikonfigurasi oleh WebSocketConfig.java
|
||||||
|
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE location_history
|
|
||||||
ADD COLUMN IF NOT EXISTS battery_level INTEGER;
|
|
||||||
@ -4,7 +4,6 @@ import com.walkguide.dto.request.CallNotifyRequest;
|
|||||||
import com.walkguide.entity.User;
|
import com.walkguide.entity.User;
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
import com.walkguide.repository.UserRepository;
|
import com.walkguide.repository.UserRepository;
|
||||||
import com.walkguide.websocket.LocationBroadcaster;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@ -35,9 +34,6 @@ class CallNotificationServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private UserRepository userRepository;
|
private UserRepository userRepository;
|
||||||
|
|
||||||
@Mock
|
|
||||||
private LocationBroadcaster locationBroadcaster;
|
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private CallNotificationService service;
|
private CallNotificationService service;
|
||||||
|
|
||||||
@ -93,7 +89,7 @@ class CallNotificationServiceTest {
|
|||||||
|
|
||||||
String message = service.notifyIncomingCall(1L, request);
|
String message = service.notifyIncomingCall(1L, request);
|
||||||
|
|
||||||
assertEquals("Panggilan dikirim via realtime fallback.", message);
|
assertEquals("Panggilan dikirim (receiver mungkin tidak menerima push notification)", message);
|
||||||
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
|
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import com.walkguide.entity.SosEvent;
|
|||||||
import com.walkguide.entity.User;
|
import com.walkguide.entity.User;
|
||||||
import com.walkguide.enums.PairingStatus;
|
import com.walkguide.enums.PairingStatus;
|
||||||
import com.walkguide.enums.SosStatus;
|
import com.walkguide.enums.SosStatus;
|
||||||
import com.walkguide.exception.PairingException;
|
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
import com.walkguide.repository.*;
|
import com.walkguide.repository.*;
|
||||||
import com.walkguide.websocket.LocationBroadcaster;
|
import com.walkguide.websocket.LocationBroadcaster;
|
||||||
@ -83,7 +82,7 @@ class SosServiceTest {
|
|||||||
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
|
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
|
||||||
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
||||||
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
||||||
.thenReturn(Optional.of(activePairing));
|
.thenReturn(Optional.empty()); // tidak ada guardian → skip FCM
|
||||||
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
|
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
|
||||||
|
|
||||||
SosEventResponse result = sosService.triggerSos(2L, req);
|
SosEventResponse result = sosService.triggerSos(2L, req);
|
||||||
@ -107,7 +106,7 @@ class SosServiceTest {
|
|||||||
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
|
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
|
||||||
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
||||||
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
||||||
.thenReturn(Optional.of(activePairing));
|
.thenReturn(Optional.empty());
|
||||||
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
|
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
|
||||||
|
|
||||||
ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class);
|
ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class);
|
||||||
@ -148,28 +147,13 @@ class SosServiceTest {
|
|||||||
SosRequest req = new SosRequest();
|
SosRequest req = new SosRequest();
|
||||||
req.setTriggerType("MANUAL");
|
req.setTriggerType("MANUAL");
|
||||||
|
|
||||||
|
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
|
||||||
when(userRepository.findById(99L)).thenReturn(Optional.empty());
|
when(userRepository.findById(99L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> sosService.triggerSos(99L, req))
|
assertThatThrownBy(() -> sosService.triggerSos(99L, req))
|
||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("triggerSos - tanpa pairing aktif: harus throw PairingException dan tidak simpan SOS")
|
|
||||||
void triggerSos_unpaired_shouldThrowPairingException() {
|
|
||||||
SosRequest req = new SosRequest();
|
|
||||||
req.setTriggerType("MANUAL");
|
|
||||||
|
|
||||||
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
|
||||||
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
|
||||||
.thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> sosService.triggerSos(2L, req))
|
|
||||||
.isInstanceOf(PairingException.class)
|
|
||||||
.hasMessageContaining("Guardian aktif");
|
|
||||||
verify(sosEventRepository, never()).save(any(SosEvent.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== acknowledgeSos TESTS =====
|
// ===== acknowledgeSos TESTS =====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -5,10 +5,6 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file("google-services.json").exists()) {
|
|
||||||
apply(plugin = "com.google.gms.google-services")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.walkguide_app"
|
namespace = "com.example.walkguide_app"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
|||||||
@ -2,9 +2,6 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
@ -16,9 +13,7 @@
|
|||||||
<application
|
<application
|
||||||
android:label="WalkGuide"
|
android:label="WalkGuide"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher">
|
||||||
android:enableOnBackInvokedCallback="true"
|
|
||||||
android:usesCleartextTraffic="true">
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@ -39,9 +34,6 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
<meta-data
|
|
||||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
|
||||||
android:value="false" />
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
<!-- You can insert your own image assets here -->
|
||||||
<!-- <item>
|
<!-- <item>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
@ -12,7 +12,7 @@
|
|||||||
running.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">#F8FAFC</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
org.gradle.workers.max=2
|
|
||||||
org.gradle.parallel=false
|
|
||||||
org.gradle.daemon=false
|
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
kotlin.incremental=false
|
kotlin.incremental=false
|
||||||
@ -21,7 +21,6 @@ plugins {
|
|||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.9.1" apply false
|
id("com.android.application") version "8.9.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
id("com.google.gms.google-services") version "4.4.2" apply false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
@ -1,65 +1,31 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import 'app_cubit.dart';
|
import 'app_cubit.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
import '../core/i18n/app_strings.dart';
|
|
||||||
import '../core/theme/app_colors.dart';
|
|
||||||
import '../core/theme/app_decorations.dart';
|
|
||||||
import '../core/theme/app_text_styles.dart';
|
|
||||||
|
|
||||||
class WalkGuideApp extends StatelessWidget {
|
class WalkGuideApp extends StatelessWidget {
|
||||||
const WalkGuideApp({super.key});
|
const WalkGuideApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const seed = AppColors.primaryBlue;
|
const seed = Color(0xFF1A56DB);
|
||||||
|
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (_) => AppCubit(),
|
create: (_) => AppCubit(),
|
||||||
child: BlocBuilder<AppCubit, AppState>(
|
child: MaterialApp.router(
|
||||||
builder: (context, state) => MaterialApp.router(
|
|
||||||
title: 'WalkGuide',
|
title: 'WalkGuide',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
routerConfig: appRouter,
|
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(
|
theme: ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
visualDensity: VisualDensity.adaptivePlatformDensity,
|
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: seed,
|
seedColor: seed,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
primary: seed,
|
|
||||||
secondary: AppColors.accent,
|
|
||||||
error: AppColors.danger,
|
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: AppColors.surface,
|
|
||||||
textTheme: AppTextStyles.textTheme.apply(
|
|
||||||
bodyColor: AppColors.text,
|
|
||||||
displayColor: AppColors.text,
|
|
||||||
),
|
),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFFF4F7FB),
|
||||||
|
textTheme: GoogleFonts.interTextTheme(),
|
||||||
pageTransitionsTheme: const PageTransitionsTheme(
|
pageTransitionsTheme: const PageTransitionsTheme(
|
||||||
builders: {
|
builders: {
|
||||||
TargetPlatform.android: ZoomPageTransitionsBuilder(),
|
TargetPlatform.android: ZoomPageTransitionsBuilder(),
|
||||||
@ -69,39 +35,16 @@ class WalkGuideApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: Color(0xFFF4F7FB),
|
||||||
foregroundColor: AppColors.text,
|
foregroundColor: Color(0xFF0F172A),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
),
|
),
|
||||||
cardTheme: const CardThemeData(
|
|
||||||
elevation: 0,
|
|
||||||
color: AppColors.surfaceRaised,
|
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
shape: AppDecorations.cardShape,
|
|
||||||
),
|
|
||||||
dividerTheme: const DividerThemeData(
|
|
||||||
color: AppColors.border,
|
|
||||||
thickness: 1,
|
|
||||||
space: 1,
|
|
||||||
),
|
|
||||||
iconButtonTheme: IconButtonThemeData(
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.text,
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
side: const BorderSide(color: AppColors.border),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
navigationBarTheme: NavigationBarThemeData(
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
height: 76,
|
height: 76,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white.withValues(alpha: 0.96),
|
||||||
indicatorColor: AppColors.softBlueBg,
|
indicatorColor: const Color(0xFFE0E7FF),
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||||
(states) => TextStyle(
|
(states) => TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@ -116,12 +59,9 @@ class WalkGuideApp extends StatelessWidget {
|
|||||||
backgroundColor: seed,
|
backgroundColor: seed,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
minimumSize: const Size(0, 50),
|
minimumSize: const Size(0, 50),
|
||||||
textStyle: AppTextStyles.body.copyWith(
|
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -129,48 +69,33 @@ class WalkGuideApp extends StatelessWidget {
|
|||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
minimumSize: const Size(0, 50),
|
minimumSize: const Size(0, 50),
|
||||||
foregroundColor: seed,
|
foregroundColor: seed,
|
||||||
textStyle: AppTextStyles.body.copyWith(
|
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||||
color: seed,
|
side: const BorderSide(color: Color(0xFFCBD5E1)),
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
side: const BorderSide(color: AppColors.border),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(50),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
snackBarTheme: SnackBarThemeData(
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
backgroundColor: AppColors.text,
|
|
||||||
contentTextStyle: AppTextStyles.body.copyWith(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: const Color(0xFFF8FAFC),
|
||||||
contentPadding:
|
contentPadding:
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: const BorderSide(color: AppColors.border),
|
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: const BorderSide(color: AppColors.border),
|
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: const BorderSide(color: seed, width: 1.5),
|
borderSide: const BorderSide(color: seed, width: 1.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,26 +4,14 @@ class AppState {
|
|||||||
final bool online;
|
final bool online;
|
||||||
final String? role;
|
final String? role;
|
||||||
final String? serverUrl;
|
final String? serverUrl;
|
||||||
final String localeCode;
|
|
||||||
|
|
||||||
const AppState({
|
const AppState({required this.online, this.role, this.serverUrl});
|
||||||
required this.online,
|
|
||||||
this.role,
|
|
||||||
this.serverUrl,
|
|
||||||
this.localeCode = 'id-ID',
|
|
||||||
});
|
|
||||||
|
|
||||||
AppState copyWith({
|
AppState copyWith({bool? online, String? role, String? serverUrl}) {
|
||||||
bool? online,
|
|
||||||
String? role,
|
|
||||||
String? serverUrl,
|
|
||||||
String? localeCode,
|
|
||||||
}) {
|
|
||||||
return AppState(
|
return AppState(
|
||||||
online: online ?? this.online,
|
online: online ?? this.online,
|
||||||
role: role ?? this.role,
|
role: role ?? this.role,
|
||||||
serverUrl: serverUrl ?? this.serverUrl,
|
serverUrl: serverUrl ?? this.serverUrl,
|
||||||
localeCode: localeCode ?? this.localeCode,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -37,7 +25,5 @@ class AppCubit extends Cubit<AppState> {
|
|||||||
|
|
||||||
void setOnline(bool value) => emit(state.copyWith(online: value));
|
void setOnline(bool value) => emit(state.copyWith(online: value));
|
||||||
|
|
||||||
void setLocaleCode(String value) => emit(state.copyWith(localeCode: value));
|
|
||||||
|
|
||||||
void clearSession() => emit(const AppState(online: true));
|
void clearSession() => emit(const AppState(online: true));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../core/constants/app_constants.dart';
|
import '../core/constants/app_constants.dart';
|
||||||
import '../core/ai/obstacle_alert_strategy.dart';
|
import '../core/ai/obstacle_alert_strategy.dart';
|
||||||
import '../core/ai/obstacle_analyzer.dart';
|
import '../core/ai/obstacle_analyzer.dart';
|
||||||
@ -8,7 +10,6 @@ import '../core/services/haptic_service.dart';
|
|||||||
import '../core/services/call_service.dart';
|
import '../core/services/call_service.dart';
|
||||||
import '../core/services/fcm_service.dart';
|
import '../core/services/fcm_service.dart';
|
||||||
import '../core/services/hardware_shortcut_listener.dart';
|
import '../core/services/hardware_shortcut_listener.dart';
|
||||||
import '../core/services/incoming_call_polling_service.dart';
|
|
||||||
import '../core/services/location_reporter_service.dart';
|
import '../core/services/location_reporter_service.dart';
|
||||||
import '../core/services/offline_queue_service.dart';
|
import '../core/services/offline_queue_service.dart';
|
||||||
import '../core/services/stt_service.dart';
|
import '../core/services/stt_service.dart';
|
||||||
@ -17,6 +18,7 @@ import '../core/services/voice_command_handler.dart';
|
|||||||
import '../core/services/websocket_service.dart';
|
import '../core/services/websocket_service.dart';
|
||||||
import '../core/storage/local_database.dart';
|
import '../core/storage/local_database.dart';
|
||||||
import '../core/storage/secure_storage.dart';
|
import '../core/storage/secure_storage.dart';
|
||||||
|
import '../core/utils/init_guard.dart';
|
||||||
import '../features/notifications/application/notification_cubit.dart';
|
import '../features/notifications/application/notification_cubit.dart';
|
||||||
import '../features/notifications/data/repositories/notification_repository_impl.dart';
|
import '../features/notifications/data/repositories/notification_repository_impl.dart';
|
||||||
import '../features/notifications/domain/repositories/notification_repository.dart';
|
import '../features/notifications/domain/repositories/notification_repository.dart';
|
||||||
@ -37,24 +39,17 @@ Future<void> initDependencies() async {
|
|||||||
sl.registerLazySingleton<SttService>(() => SttService());
|
sl.registerLazySingleton<SttService>(() => SttService());
|
||||||
sl.registerLazySingleton<HapticService>(() => HapticService());
|
sl.registerLazySingleton<HapticService>(() => HapticService());
|
||||||
sl.registerLazySingleton<ObstacleAlertStrategy>(
|
sl.registerLazySingleton<ObstacleAlertStrategy>(
|
||||||
() => TtsWithHapticObstacleAlertStrategy(
|
() => TtsWithHapticObstacleAlertStrategy(sl<TtsService>(), sl<HapticService>()),
|
||||||
sl<TtsService>(), sl<HapticService>()),
|
|
||||||
);
|
);
|
||||||
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
|
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
|
||||||
sl.registerLazySingleton<YoloDetector>(
|
sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>()));
|
||||||
() => YoloDetector(sl<ObstacleAnalyzer>()));
|
|
||||||
sl.registerLazySingleton<OfflineQueueService>(
|
sl.registerLazySingleton<OfflineQueueService>(
|
||||||
() => OfflineQueueService(sl<LocalDatabase>()),
|
() => OfflineQueueService(sl<LocalDatabase>()),
|
||||||
);
|
);
|
||||||
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
|
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
|
||||||
sl.registerLazySingleton<WebSocketService>(
|
sl.registerLazySingleton<WebSocketService>(() => WebSocketService(sl<SecureStorage>()));
|
||||||
() => WebSocketService(sl<SecureStorage>()));
|
sl.registerLazySingleton<LocationReporterService>(() => LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
|
||||||
sl.registerLazySingleton<LocationReporterService>(() =>
|
|
||||||
LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
|
|
||||||
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
|
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
|
||||||
sl.registerLazySingleton<IncomingCallPollingService>(
|
|
||||||
() => IncomingCallPollingService(sl<ApiClient>()),
|
|
||||||
);
|
|
||||||
sl.registerLazySingleton<HardwareShortcutListener>(
|
sl.registerLazySingleton<HardwareShortcutListener>(
|
||||||
() => HardwareShortcutListener(sl<ApiClient>()),
|
() => HardwareShortcutListener(sl<ApiClient>()),
|
||||||
);
|
);
|
||||||
@ -64,10 +59,8 @@ Future<void> initDependencies() async {
|
|||||||
sl.registerLazySingleton<WalkGuideRepository>(
|
sl.registerLazySingleton<WalkGuideRepository>(
|
||||||
() => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()),
|
() => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()),
|
||||||
);
|
);
|
||||||
sl.registerFactory<WalkGuideCubit>(
|
sl.registerFactory<WalkGuideCubit>(() => WalkGuideCubit(sl<WalkGuideRepository>()));
|
||||||
() => WalkGuideCubit(sl<WalkGuideRepository>()));
|
sl.registerLazySingleton<SosRepository>(() => SosRepositoryImpl(sl<ApiClient>()));
|
||||||
sl.registerLazySingleton<SosRepository>(
|
|
||||||
() => SosRepositoryImpl(sl<ApiClient>()));
|
|
||||||
sl.registerFactory<SosCubit>(() => SosCubit(sl<SosRepository>()));
|
sl.registerFactory<SosCubit>(() => SosCubit(sl<SosRepository>()));
|
||||||
sl.registerLazySingleton<NotificationRepository>(
|
sl.registerLazySingleton<NotificationRepository>(
|
||||||
() => NotificationRepositoryImpl(sl<ApiClient>(), sl<LocalDatabase>()),
|
() => NotificationRepositoryImpl(sl<ApiClient>(), sl<LocalDatabase>()),
|
||||||
@ -81,5 +74,13 @@ Future<void> initDependencies() async {
|
|||||||
await sl<ApiClient>().init(serverUrl);
|
await sl<ApiClient>().init(serverUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
sl<VoiceCommandHandler>().loadDefaultCommands();
|
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,13 +25,11 @@ import '../features/guardian_dashboard/presentation/screens/guardian_tools_scree
|
|||||||
as guardian_tools;
|
as guardian_tools;
|
||||||
import '../features/home/presentation/guardian_dashboard_screen.dart'
|
import '../features/home/presentation/guardian_dashboard_screen.dart'
|
||||||
as guardian_home;
|
as guardian_home;
|
||||||
import '../features/manual/manual_screen.dart' as manual;
|
|
||||||
import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart'
|
import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart'
|
||||||
as nav;
|
as nav;
|
||||||
import '../features/notifications/presentation/screens/notification_screen.dart'
|
import '../features/notifications/presentation/screens/notification_screen.dart'
|
||||||
as notifications;
|
as notifications;
|
||||||
import '../features/pairing/presentation/screens/pairing_screens.dart'
|
import '../features/pairing/presentation/screens/pairing_screens.dart' as pairing;
|
||||||
as pairing;
|
|
||||||
import '../features/server_connect/server_connect_server.dart'
|
import '../features/server_connect/server_connect_server.dart'
|
||||||
as server_connect;
|
as server_connect;
|
||||||
import '../features/settings/presentation/screens/user_settings_screen.dart'
|
import '../features/settings/presentation/screens/user_settings_screen.dart'
|
||||||
@ -42,12 +40,10 @@ import '../features/walk_guide/presentation/screens/walk_guide_screen.dart'
|
|||||||
import '../shared/widgets/app_shells.dart';
|
import '../shared/widgets/app_shells.dart';
|
||||||
|
|
||||||
final GoRouter appRouter = GoRouter(
|
final GoRouter appRouter = GoRouter(
|
||||||
initialLocation: '/server-connect',
|
initialLocation: '/splash',
|
||||||
redirect: (context, state) async {
|
redirect: (context, state) async {
|
||||||
final path = state.matchedLocation;
|
final path = state.matchedLocation;
|
||||||
final serverUrl = await AppConstants.getServerUrl();
|
final serverUrl = await AppConstants.getServerUrl();
|
||||||
final isEditingServer =
|
|
||||||
path == '/server-connect' && state.uri.queryParameters['edit'] == '1';
|
|
||||||
final isPublicRoute = path == '/server-connect' ||
|
final isPublicRoute = path == '/server-connect' ||
|
||||||
path == '/splash' ||
|
path == '/splash' ||
|
||||||
path == '/login' ||
|
path == '/login' ||
|
||||||
@ -56,8 +52,7 @@ final GoRouter appRouter = GoRouter(
|
|||||||
if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') {
|
if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') {
|
||||||
return '/server-connect';
|
return '/server-connect';
|
||||||
}
|
}
|
||||||
if (!isEditingServer &&
|
if (path == '/server-connect' &&
|
||||||
path == '/server-connect' &&
|
|
||||||
serverUrl != null &&
|
serverUrl != null &&
|
||||||
serverUrl.isNotEmpty) {
|
serverUrl.isNotEmpty) {
|
||||||
return '/splash';
|
return '/splash';
|
||||||
@ -92,9 +87,7 @@ final GoRouter appRouter = GoRouter(
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/server-connect',
|
path: '/server-connect',
|
||||||
builder: (_, state) => server_connect.ServerConnectScreen(
|
builder: (_, __) => const server_connect.ServerConnectScreen()),
|
||||||
editMode: state.uri.queryParameters['edit'] == '1',
|
|
||||||
)),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
|
path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
|
||||||
GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()),
|
GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()),
|
||||||
@ -103,18 +96,7 @@ final GoRouter appRouter = GoRouter(
|
|||||||
builder: (_, __) => const auth_register.RegisterScreen()),
|
builder: (_, __) => const auth_register.RegisterScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/incoming-call',
|
path: '/incoming-call',
|
||||||
builder: (_, state) {
|
builder: (_, __) => const call.IncomingCallScreen()),
|
||||||
final extra = state.extra is Map
|
|
||||||
? Map<String, dynamic>.from(state.extra as Map)
|
|
||||||
: <String, dynamic>{};
|
|
||||||
return call.IncomingCallScreen(
|
|
||||||
callerName: extra['callerName']?.toString() ?? 'Guardian',
|
|
||||||
callerId: int.tryParse(extra['callerId']?.toString() ?? ''),
|
|
||||||
channelName: extra['channelName']?.toString(),
|
|
||||||
agoraToken: extra['agoraToken']?.toString(),
|
|
||||||
agoraAppId: extra['agoraAppId']?.toString(),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (_, __, child) => UserShell(child: child),
|
builder: (_, __, child) => UserShell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
@ -142,9 +124,6 @@ final GoRouter appRouter = GoRouter(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/benchmark',
|
path: '/user/benchmark',
|
||||||
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
||||||
GoRoute(
|
|
||||||
path: '/user/manual',
|
|
||||||
builder: (_, __) => const manual.ManualScreen()),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
@ -182,12 +161,6 @@ final GoRouter appRouter = GoRouter(
|
|||||||
path: '/guardian/settings',
|
path: '/guardian/settings',
|
||||||
builder: (_, __) =>
|
builder: (_, __) =>
|
||||||
const guardian_settings.GuardianSettingsScreen()),
|
const guardian_settings.GuardianSettingsScreen()),
|
||||||
GoRoute(
|
|
||||||
path: '/guardian/call',
|
|
||||||
builder: (_, __) => const call.CallScreen(
|
|
||||||
targetLabel: 'User',
|
|
||||||
returnRoute: '/guardian/dashboard',
|
|
||||||
)),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/benchmark',
|
path: '/guardian/benchmark',
|
||||||
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
||||||
|
|||||||
@ -586,31 +586,14 @@ const Set<String> _walkGuideObstacleLabels = {
|
|||||||
'bicycle',
|
'bicycle',
|
||||||
'car',
|
'car',
|
||||||
'motorcycle',
|
'motorcycle',
|
||||||
'truck',
|
|
||||||
'bus',
|
'bus',
|
||||||
'train',
|
'train',
|
||||||
'boat',
|
'truck',
|
||||||
'traffic light',
|
'traffic light',
|
||||||
'fire hydrant',
|
'fire hydrant',
|
||||||
'stop sign',
|
'stop sign',
|
||||||
'parking meter',
|
'parking meter',
|
||||||
'bench',
|
'bench',
|
||||||
'stairs',
|
|
||||||
'stair',
|
|
||||||
'pothole',
|
|
||||||
'curb',
|
|
||||||
'pole',
|
|
||||||
'bollard',
|
|
||||||
'cone',
|
|
||||||
'road cone',
|
|
||||||
'barrier',
|
|
||||||
'fence',
|
|
||||||
'door',
|
|
||||||
'trash can',
|
|
||||||
'signboard',
|
|
||||||
'crosswalk',
|
|
||||||
'sidewalk',
|
|
||||||
'wall',
|
|
||||||
'backpack',
|
'backpack',
|
||||||
'umbrella',
|
'umbrella',
|
||||||
'handbag',
|
'handbag',
|
||||||
@ -625,7 +608,6 @@ const Set<String> _walkGuideObstacleLabels = {
|
|||||||
'bottle',
|
'bottle',
|
||||||
'cup',
|
'cup',
|
||||||
'book',
|
'book',
|
||||||
'object',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const Map<int, String> _cocoObstacleLabels = {
|
const Map<int, String> _cocoObstacleLabels = {
|
||||||
|
|||||||
@ -1,19 +1,13 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class AppConstants {
|
class AppConstants {
|
||||||
static const String defaultServerUrl = 'http://127.0.0.1:8080';
|
|
||||||
static const String publicServerUrl = 'http://202.46.28.170:8080';
|
|
||||||
static const String _serverUrlKey = 'server_base_url';
|
static const String _serverUrlKey = 'server_base_url';
|
||||||
static const String _selectedYoloModelKey = 'selected_yolo_model';
|
static const String _selectedYoloModelKey = 'selected_yolo_model';
|
||||||
|
|
||||||
// Ambil base URL dari SharedPreferences
|
// Ambil base URL dari SharedPreferences
|
||||||
static Future<String?> getServerUrl() async {
|
static Future<String?> getServerUrl() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final saved = prefs.getString(_serverUrlKey);
|
return prefs.getString(_serverUrlKey);
|
||||||
if (saved == null || saved.trim().isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simpan URL setelah berhasil connect
|
// Simpan URL setelah berhasil connect
|
||||||
@ -28,9 +22,6 @@ class AppConstants {
|
|||||||
if (!cleaned.startsWith('http://') && !cleaned.startsWith('https://')) {
|
if (!cleaned.startsWith('http://') && !cleaned.startsWith('https://')) {
|
||||||
cleaned = 'http://$cleaned';
|
cleaned = 'http://$cleaned';
|
||||||
}
|
}
|
||||||
cleaned = cleaned
|
|
||||||
.replaceFirst('://localhost', '://127.0.0.1')
|
|
||||||
.replaceFirst('://0.0.0.0', '://127.0.0.1');
|
|
||||||
while (cleaned.endsWith('/')) {
|
while (cleaned.endsWith('/')) {
|
||||||
cleaned = cleaned.substring(0, cleaned.length - 1);
|
cleaned = cleaned.substring(0, cleaned.length - 1);
|
||||||
}
|
}
|
||||||
@ -70,6 +61,7 @@ class AppConstants {
|
|||||||
await prefs.setString(_selectedYoloModelKey, path);
|
await prefs.setString(_selectedYoloModelKey, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set at build time with: --dart-define=AGORA_APP_ID=your_app_id
|
// Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=...
|
||||||
static const String agoraAppId = String.fromEnvironment('AGORA_APP_ID');
|
static const String agoraAppId =
|
||||||
|
String.fromEnvironment('AGORA_APP_ID', defaultValue: '');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,12 +71,6 @@ bool _looksTechnical(String message) {
|
|||||||
'null check operator',
|
'null check operator',
|
||||||
'nosuchmethod',
|
'nosuchmethod',
|
||||||
'formatexception',
|
'formatexception',
|
||||||
'could not execute statement',
|
|
||||||
'duplicate key',
|
|
||||||
'constraint',
|
|
||||||
'sql [',
|
|
||||||
'illegal base64',
|
|
||||||
'base64 character',
|
|
||||||
];
|
];
|
||||||
return blocked.any(lower.contains);
|
return blocked.any(lower.contains);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,9 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
|
||||||
|
|
||||||
class AppStrings {
|
class AppStrings {
|
||||||
final String localeCode;
|
final String localeCode;
|
||||||
|
|
||||||
const AppStrings(this.localeCode);
|
const AppStrings(this.localeCode);
|
||||||
|
|
||||||
static const supportedLocales = [
|
static const supportedLocales = ['id-ID', 'en-US'];
|
||||||
Locale('id', 'ID'),
|
|
||||||
Locale('en', 'US'),
|
|
||||||
];
|
|
||||||
|
|
||||||
static AppStrings of(BuildContext context) {
|
|
||||||
return Localizations.of<AppStrings>(context, AppStrings) ??
|
|
||||||
const AppStrings('id-ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
String get walkGuideStarted => _pick(
|
String get walkGuideStarted => _pick(
|
||||||
id: 'WalkGuide dimulai',
|
id: 'WalkGuide dimulai',
|
||||||
@ -39,21 +29,3 @@ class AppStrings {
|
|||||||
return localeCode == 'en-US' ? en : id;
|
return localeCode == 'en-US' ? en : id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppStringsDelegate extends LocalizationsDelegate<AppStrings> {
|
|
||||||
const AppStringsDelegate();
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool isSupported(Locale locale) {
|
|
||||||
return locale.languageCode == 'id' || locale.languageCode == 'en';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<AppStrings> load(Locale locale) async {
|
|
||||||
final code = locale.languageCode == 'en' ? 'en-US' : 'id-ID';
|
|
||||||
return AppStrings(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldReload(covariant LocalizationsDelegate<AppStrings> old) => false;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import '../constants/app_constants.dart';
|
import '../constants/app_constants.dart';
|
||||||
import '../storage/secure_storage.dart';
|
import '../storage/secure_storage.dart';
|
||||||
|
|
||||||
@ -25,15 +24,8 @@ class ApiClient {
|
|||||||
_dio.interceptors.addAll([
|
_dio.interceptors.addAll([
|
||||||
_AuthInterceptor(_secureStorage, _dio),
|
_AuthInterceptor(_secureStorage, _dio),
|
||||||
_ErrorInterceptor(),
|
_ErrorInterceptor(),
|
||||||
|
LogInterceptor(requestBody: true, responseBody: true),
|
||||||
]);
|
]);
|
||||||
if (kDebugMode) {
|
|
||||||
_dio.interceptors.add(LogInterceptor(
|
|
||||||
requestBody: false,
|
|
||||||
responseBody: false,
|
|
||||||
requestHeader: false,
|
|
||||||
responseHeader: false,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Dio get dio => _dio;
|
Dio get dio => _dio;
|
||||||
@ -50,8 +42,7 @@ class _AuthInterceptor extends Interceptor {
|
|||||||
_AuthInterceptor(this._storage, this._dio);
|
_AuthInterceptor(this._storage, this._dio);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onRequest(
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||||
RequestOptions options, RequestInterceptorHandler handler) async {
|
|
||||||
final token = await _storage.getAccessToken();
|
final token = await _storage.getAccessToken();
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
@ -61,11 +52,7 @@ class _AuthInterceptor extends Interceptor {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
final status = err.response?.statusCode;
|
if (err.response?.statusCode == 401 && !_refreshing) {
|
||||||
final canRefresh = (status == 401 || status == 403) &&
|
|
||||||
!_refreshing &&
|
|
||||||
!err.requestOptions.path.startsWith('/auth/');
|
|
||||||
if (canRefresh) {
|
|
||||||
_refreshing = true;
|
_refreshing = true;
|
||||||
try {
|
try {
|
||||||
final refresh = await _storage.getRefreshToken();
|
final refresh = await _storage.getRefreshToken();
|
||||||
@ -91,20 +78,14 @@ class _AuthInterceptor extends Interceptor {
|
|||||||
// Retry original request
|
// Retry original request
|
||||||
err.requestOptions.headers['Authorization'] =
|
err.requestOptions.headers['Authorization'] =
|
||||||
'Bearer ${data['accessToken']}';
|
'Bearer ${data['accessToken']}';
|
||||||
try {
|
|
||||||
final retryRes = await _dio.fetch(err.requestOptions);
|
final retryRes = await _dio.fetch(err.requestOptions);
|
||||||
_refreshing = false;
|
_refreshing = false;
|
||||||
handler.resolve(retryRes);
|
handler.resolve(retryRes);
|
||||||
} on DioException catch (retryErr) {
|
|
||||||
_refreshing = false;
|
|
||||||
handler.next(retryErr);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
await _storage.clearAll();
|
|
||||||
}
|
|
||||||
_refreshing = false;
|
_refreshing = false;
|
||||||
|
await _storage.clearAll();
|
||||||
}
|
}
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
|
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
|
||||||
|
|
||||||
import '../constants/app_constants.dart';
|
import '../constants/app_constants.dart';
|
||||||
import '../network/api_client.dart';
|
import '../network/api_client.dart';
|
||||||
@ -10,19 +7,9 @@ import '../network/api_client.dart';
|
|||||||
class CallService {
|
class CallService {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
RtcEngine? _engine;
|
RtcEngine? _engine;
|
||||||
VoidCallback? _onRemoteUserJoined;
|
|
||||||
VoidCallback? _onRemoteUserOffline;
|
|
||||||
|
|
||||||
CallService(this._apiClient);
|
CallService(this._apiClient);
|
||||||
|
|
||||||
void setRemoteUserJoinedCallback(VoidCallback? callback) {
|
|
||||||
_onRemoteUserJoined = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setRemoteUserOfflineCallback(VoidCallback? callback) {
|
|
||||||
_onRemoteUserOffline = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> requestToken({required int receiverId}) async {
|
Future<Map<String, dynamic>?> requestToken({required int receiverId}) async {
|
||||||
final res = await _apiClient.dio.post(
|
final res = await _apiClient.dio.post(
|
||||||
'/shared/call/token',
|
'/shared/call/token',
|
||||||
@ -44,219 +31,72 @@ class CallService {
|
|||||||
required int receiverId,
|
required int receiverId,
|
||||||
required String channelName,
|
required String channelName,
|
||||||
String? agoraToken,
|
String? agoraToken,
|
||||||
String? agoraAppId,
|
|
||||||
int receiverUid = 0,
|
int receiverUid = 0,
|
||||||
}) async {
|
}) async {
|
||||||
await _apiClient.dio.post(
|
await _apiClient.dio.post('/shared/call/notify', data: {
|
||||||
'/shared/call/notify',
|
|
||||||
data: {
|
|
||||||
'receiverId': receiverId,
|
'receiverId': receiverId,
|
||||||
'channelName': channelName,
|
'channelName': channelName,
|
||||||
'agoraToken': agoraToken,
|
'agoraToken': agoraToken,
|
||||||
'agoraAppId': agoraAppId,
|
|
||||||
'receiverUid': receiverUid,
|
'receiverUid': receiverUid,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> startPairedCall({int uid = 0}) async {
|
Future<bool> callPairedUser({int uid = 0}) async {
|
||||||
final receiverId = await getPairedReceiverId();
|
final receiverId = await getPairedReceiverId();
|
||||||
if (receiverId == null) return null;
|
if (receiverId == null) return false;
|
||||||
|
|
||||||
final tokenData = await requestToken(receiverId: receiverId);
|
final tokenData = await requestToken(receiverId: receiverId);
|
||||||
final channelName = tokenData?['channelName']?.toString();
|
final channelName = tokenData?['channelName']?.toString();
|
||||||
final token = tokenData?['token']?.toString();
|
final token = tokenData?['token']?.toString();
|
||||||
final appId = tokenData?['appId']?.toString();
|
if (channelName == null || channelName.isEmpty) return false;
|
||||||
final localUid = (tokenData?['uid'] as num?)?.toInt() ?? uid;
|
|
||||||
if (channelName == null || channelName.isEmpty) return null;
|
|
||||||
|
|
||||||
final joined = await joinChannel(
|
final joined = await joinChannel(
|
||||||
channelName: channelName,
|
channelName: channelName,
|
||||||
token: token,
|
token: token,
|
||||||
appId: appId,
|
uid: uid,
|
||||||
uid: localUid,
|
|
||||||
);
|
);
|
||||||
if (!joined) return null;
|
if (joined) {
|
||||||
|
|
||||||
await notifyIncomingCall(
|
await notifyIncomingCall(
|
||||||
receiverId: receiverId,
|
receiverId: receiverId,
|
||||||
channelName: channelName,
|
channelName: channelName,
|
||||||
agoraToken: token,
|
agoraToken: token,
|
||||||
agoraAppId: appId,
|
receiverUid: uid,
|
||||||
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},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return joined;
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> joinChannel({
|
Future<bool> joinChannel({
|
||||||
required String channelName,
|
required String channelName,
|
||||||
String? token,
|
String? token,
|
||||||
String? appId,
|
|
||||||
int uid = 0,
|
int uid = 0,
|
||||||
}) async {
|
}) async {
|
||||||
final joinCompleter = Completer<bool>();
|
|
||||||
try {
|
try {
|
||||||
final resolvedAppId =
|
if (AppConstants.agoraAppId.isEmpty) {
|
||||||
(appId != null && appId.isNotEmpty) ? appId : AppConstants.agoraAppId;
|
|
||||||
if (resolvedAppId.isEmpty) {
|
|
||||||
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
|
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!await _ensureMicrophonePermission()) {
|
|
||||||
debugPrint('Agora join skipped: microphone permission denied');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_engine ??= createAgoraRtcEngine();
|
_engine ??= createAgoraRtcEngine();
|
||||||
await _engine!.initialize(RtcEngineContext(appId: resolvedAppId));
|
await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId));
|
||||||
_engine!.registerEventHandler(
|
|
||||||
RtcEngineEventHandler(
|
|
||||||
onJoinChannelSuccess: (_, __) {
|
|
||||||
if (!joinCompleter.isCompleted) joinCompleter.complete(true);
|
|
||||||
},
|
|
||||||
onUserJoined: (_, remoteUid, __) {
|
|
||||||
debugPrint('Agora remote user joined: $remoteUid');
|
|
||||||
_onRemoteUserJoined?.call();
|
|
||||||
},
|
|
||||||
onUserOffline: (_, remoteUid, reason) {
|
|
||||||
debugPrint('Agora remote user offline: $remoteUid $reason');
|
|
||||||
_onRemoteUserOffline?.call();
|
|
||||||
},
|
|
||||||
onRemoteAudioStateChanged: (_, remoteUid, state, reason, __) {
|
|
||||||
debugPrint(
|
|
||||||
'Agora remote audio state: uid=$remoteUid state=$state reason=$reason',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (type, msg) {
|
|
||||||
debugPrint('Agora error: $type $msg');
|
|
||||||
if (!joinCompleter.isCompleted) joinCompleter.complete(false);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await _engine!.setChannelProfile(
|
|
||||||
ChannelProfileType.channelProfileCommunication,
|
|
||||||
);
|
|
||||||
await _engine!.setClientRole(role: ClientRoleType.clientRoleBroadcaster);
|
|
||||||
await _engine!.setAudioProfile(
|
|
||||||
profile: AudioProfileType.audioProfileDefault,
|
|
||||||
scenario: AudioScenarioType.audioScenarioDefault,
|
|
||||||
);
|
|
||||||
await _engine!.enableAudio();
|
await _engine!.enableAudio();
|
||||||
await _engine!.enableLocalAudio(true);
|
|
||||||
await _engine!.muteAllRemoteAudioStreams(false);
|
|
||||||
await _engine!.muteLocalAudioStream(false);
|
|
||||||
await _engine!.enableAudioVolumeIndication(
|
|
||||||
interval: 500,
|
|
||||||
smooth: 3,
|
|
||||||
reportVad: true,
|
|
||||||
);
|
|
||||||
await _engine!.adjustRecordingSignalVolume(100);
|
|
||||||
await _engine!.adjustPlaybackSignalVolume(100);
|
|
||||||
await _engine!.setDefaultAudioRouteToSpeakerphone(true);
|
|
||||||
await _engine!.setEnableSpeakerphone(true);
|
|
||||||
await _engine!.joinChannel(
|
await _engine!.joinChannel(
|
||||||
token: token ?? '',
|
token: token ?? '',
|
||||||
channelId: channelName,
|
channelId: channelName,
|
||||||
uid: uid,
|
uid: uid,
|
||||||
options: const ChannelMediaOptions(
|
options: const ChannelMediaOptions(),
|
||||||
channelProfile: ChannelProfileType.channelProfileCommunication,
|
|
||||||
clientRoleType: ClientRoleType.clientRoleBroadcaster,
|
|
||||||
publishMicrophoneTrack: true,
|
|
||||||
autoSubscribeAudio: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return joinCompleter.future.timeout(
|
|
||||||
const Duration(seconds: 10),
|
|
||||||
onTimeout: () {
|
|
||||||
debugPrint('Agora join timeout for channel $channelName');
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Agora join skipped: $e');
|
debugPrint('Agora join skipped: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _ensureMicrophonePermission() async {
|
|
||||||
if (kIsWeb) return true;
|
|
||||||
final status = await Permission.microphone.request();
|
|
||||||
return status.isGranted || status.isLimited;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setMuted(bool muted) async {
|
|
||||||
await _engine?.muteLocalAudioStream(muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setSpeakerEnabled(bool enabled) async {
|
|
||||||
await _engine?.setEnableSpeakerphone(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> leave() async {
|
Future<void> leave() async {
|
||||||
_onRemoteUserJoined = null;
|
|
||||||
_onRemoteUserOffline = null;
|
|
||||||
await _engine?.leaveChannel();
|
await _engine?.leaveChannel();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
_onRemoteUserJoined = null;
|
|
||||||
_onRemoteUserOffline = null;
|
|
||||||
await _engine?.release();
|
await _engine?.release();
|
||||||
_engine = null;
|
_engine = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,62 +1,32 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
import '../../app/router.dart';
|
|
||||||
import '../network/api_client.dart';
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
|
||||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|
||||||
await Firebase.initializeApp();
|
|
||||||
}
|
|
||||||
|
|
||||||
class FcmService {
|
class FcmService {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||||
FlutterLocalNotificationsPlugin();
|
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
FcmService(this._apiClient);
|
FcmService(this._apiClient);
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
if (kIsWeb) return;
|
if (kIsWeb) return;
|
||||||
try {
|
try {
|
||||||
await Firebase.initializeApp();
|
|
||||||
FirebaseMessaging.onBackgroundMessage(
|
|
||||||
_firebaseMessagingBackgroundHandler);
|
|
||||||
final messaging = FirebaseMessaging.instance;
|
|
||||||
await _localNotifications.initialize(
|
await _localNotifications.initialize(
|
||||||
const InitializationSettings(
|
const InitializationSettings(
|
||||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||||
),
|
),
|
||||||
onDidReceiveNotificationResponse: (response) {
|
|
||||||
final payload = response.payload;
|
|
||||||
if (payload == null || payload.isEmpty) return;
|
|
||||||
try {
|
|
||||||
final data = Map<String, dynamic>.from(jsonDecode(payload) as Map);
|
|
||||||
_handlePayloadNavigation(data);
|
|
||||||
} catch (_) {}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
await messaging.requestPermission(alert: true, badge: true, sound: true);
|
await _messaging.requestPermission(alert: true, badge: true, sound: true);
|
||||||
final token = await messaging.getToken();
|
final token = await _messaging.getToken();
|
||||||
if (token != null) await syncToken(token);
|
if (token != null) await syncToken(token);
|
||||||
messaging.onTokenRefresh.listen(syncToken);
|
FirebaseMessaging.instance.onTokenRefresh.listen(syncToken);
|
||||||
FirebaseMessaging.onMessage.listen((message) {
|
FirebaseMessaging.onMessage.listen((message) {
|
||||||
debugPrint('FCM foreground: ${message.data}');
|
debugPrint('FCM foreground: ${message.data}');
|
||||||
_showLocalNotification(message);
|
_showLocalNotification(message);
|
||||||
_handlePayloadNavigation(message.data);
|
|
||||||
});
|
});
|
||||||
FirebaseMessaging.onMessageOpenedApp.listen((message) {
|
|
||||||
_handlePayloadNavigation(message.data);
|
|
||||||
});
|
|
||||||
final initialMessage =
|
|
||||||
await FirebaseMessaging.instance.getInitialMessage();
|
|
||||||
if (initialMessage != null) {
|
|
||||||
_handlePayloadNavigation(initialMessage.data);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('FCM init skipped: $e');
|
debugPrint('FCM init skipped: $e');
|
||||||
}
|
}
|
||||||
@ -64,10 +34,6 @@ class FcmService {
|
|||||||
|
|
||||||
Future<void> syncToken(String token) async {
|
Future<void> syncToken(String token) async {
|
||||||
try {
|
try {
|
||||||
if (_apiClient.baseUrl == null) {
|
|
||||||
debugPrint('FCM token sync skipped: server URL is not ready.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await _apiClient.dio.put('/auth/fcm-token', data: {'fcmToken': token});
|
await _apiClient.dio.put('/auth/fcm-token', data: {'fcmToken': token});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('FCM token sync skipped: $e');
|
debugPrint('FCM token sync skipped: $e');
|
||||||
@ -76,11 +42,8 @@ class FcmService {
|
|||||||
|
|
||||||
Future<void> _showLocalNotification(RemoteMessage message) async {
|
Future<void> _showLocalNotification(RemoteMessage message) async {
|
||||||
final notification = message.notification;
|
final notification = message.notification;
|
||||||
final title =
|
final title = notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
|
||||||
notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
|
final body = notification?.body ?? message.data['body']?.toString() ?? 'Ada update baru';
|
||||||
final body = notification?.body ??
|
|
||||||
message.data['body']?.toString() ??
|
|
||||||
'Ada update baru';
|
|
||||||
await _localNotifications.show(
|
await _localNotifications.show(
|
||||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
title,
|
title,
|
||||||
@ -94,26 +57,7 @@ class FcmService {
|
|||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
payload: jsonEncode(message.data),
|
payload: message.data['type']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,107 +1,36 @@
|
|||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:vibration/vibration.dart';
|
import 'package:vibration/vibration.dart';
|
||||||
|
|
||||||
class HapticService {
|
class HapticService {
|
||||||
bool _enabled = true;
|
Future<bool> get _hasVibrator async => Vibration.hasVibrator();
|
||||||
DateTime _lastObstacleVibrationAt = DateTime.fromMillisecondsSinceEpoch(0);
|
|
||||||
|
|
||||||
static const _obstacleCooldown = Duration(seconds: 3);
|
|
||||||
|
|
||||||
bool get enabled => _enabled;
|
|
||||||
|
|
||||||
void setEnabled(bool enabled) {
|
|
||||||
_enabled = enabled;
|
|
||||||
if (!enabled) {
|
|
||||||
Vibration.cancel().ignore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> get _hasVibrator async {
|
|
||||||
try {
|
|
||||||
final hasVibrator = await Vibration.hasVibrator();
|
|
||||||
return hasVibrator == true;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _canRunObstacleVibration() {
|
|
||||||
final now = DateTime.now();
|
|
||||||
if (now.difference(_lastObstacleVibrationAt) < _obstacleCooldown) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
_lastObstacleVibrationAt = now;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _vibrate({
|
|
||||||
int? duration,
|
|
||||||
List<int>? pattern,
|
|
||||||
required Future<void> Function() fallback,
|
|
||||||
bool obstacle = false,
|
|
||||||
}) async {
|
|
||||||
if (!_enabled) return;
|
|
||||||
if (obstacle && !_canRunObstacleVibration()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (await _hasVibrator) {
|
|
||||||
if (pattern != null) {
|
|
||||||
await Vibration.vibrate(pattern: pattern);
|
|
||||||
} else if (duration != null) {
|
|
||||||
await Vibration.vibrate(duration: duration);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Use Flutter's platform haptics below when the vibration plugin fails.
|
|
||||||
}
|
|
||||||
|
|
||||||
await fallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> obstacleVeryClose() async {
|
Future<void> obstacleVeryClose() async {
|
||||||
await _vibrate(
|
if (!await _hasVibrator) return;
|
||||||
pattern: [0, 500, 100, 500, 100, 500],
|
Vibration.vibrate(pattern: [0, 500, 100, 500, 100, 500]);
|
||||||
fallback: HapticFeedback.heavyImpact,
|
|
||||||
obstacle: true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> obstacleClose() async {
|
Future<void> obstacleClose() async {
|
||||||
await _vibrate(
|
if (!await _hasVibrator) return;
|
||||||
pattern: [0, 300, 100, 300],
|
Vibration.vibrate(pattern: [0, 300, 100, 300]);
|
||||||
fallback: HapticFeedback.mediumImpact,
|
|
||||||
obstacle: true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> obstacleMedium() async {
|
Future<void> obstacleMedium() async {
|
||||||
await _vibrate(
|
if (!await _hasVibrator) return;
|
||||||
duration: 150,
|
Vibration.vibrate(duration: 150);
|
||||||
fallback: HapticFeedback.lightImpact,
|
|
||||||
obstacle: true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sosTriggered() async {
|
Future<void> sosTriggered() async {
|
||||||
await _vibrate(
|
if (!await _hasVibrator) return;
|
||||||
pattern: [0, 1000, 200, 1000, 200, 1000],
|
Vibration.vibrate(pattern: [0, 1000, 200, 1000, 200, 1000]);
|
||||||
fallback: HapticFeedback.heavyImpact,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> callIncoming() async {
|
Future<void> callIncoming() async {
|
||||||
await _vibrate(
|
if (!await _hasVibrator) return;
|
||||||
pattern: [0, 500, 500, 500, 500, 500, 500, 500],
|
Vibration.vibrate(pattern: [0, 500, 500, 500, 500, 500, 500, 500]);
|
||||||
fallback: HapticFeedback.mediumImpact,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> success() async {
|
Future<void> success() async {
|
||||||
await _vibrate(
|
if (!await _hasVibrator) return;
|
||||||
duration: 80,
|
Vibration.vibrate(duration: 80);
|
||||||
fallback: HapticFeedback.selectionClick,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async => Vibration.cancel();
|
Future<void> stop() async => Vibration.cancel();
|
||||||
|
|||||||
@ -27,14 +27,11 @@ class HardwareShortcutBinding {
|
|||||||
class HardwareShortcutListener {
|
class HardwareShortcutListener {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
final Map<int, HardwareShortcutBinding> _bindings = {};
|
final Map<int, HardwareShortcutBinding> _bindings = {};
|
||||||
final Map<int, DateTime> _lastHandledAt = {};
|
|
||||||
|
|
||||||
bool _listening = false;
|
bool _listening = false;
|
||||||
void Function(HardwareShortcutAction action)? _onAction;
|
void Function(HardwareShortcutAction action)? _onAction;
|
||||||
void Function(int buttonCode, String buttonName)? _captureCallback;
|
void Function(int buttonCode, String buttonName)? _captureCallback;
|
||||||
|
|
||||||
static const Duration _repeatDebounce = Duration(milliseconds: 900);
|
|
||||||
|
|
||||||
HardwareShortcutListener(this._apiClient);
|
HardwareShortcutListener(this._apiClient);
|
||||||
|
|
||||||
Future<void> startListening({
|
Future<void> startListening({
|
||||||
@ -71,8 +68,7 @@ class HardwareShortcutListener {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void captureNextButton(
|
void captureNextButton(void Function(int buttonCode, String buttonName) onCapture) {
|
||||||
void Function(int buttonCode, String buttonName) onCapture) {
|
|
||||||
_captureCallback = onCapture;
|
_captureCallback = onCapture;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,12 +88,6 @@ class HardwareShortcutListener {
|
|||||||
|
|
||||||
final binding = _bindings[code];
|
final binding = _bindings[code];
|
||||||
if (binding == null || !binding.enabled) return false;
|
if (binding == null || !binding.enabled) return false;
|
||||||
final now = DateTime.now();
|
|
||||||
final lastHandled = _lastHandledAt[code];
|
|
||||||
if (lastHandled != null && now.difference(lastHandled) < _repeatDebounce) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
_lastHandledAt[code] = now;
|
|
||||||
_onAction?.call(binding.action);
|
_onAction?.call(binding.action);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -113,8 +103,7 @@ HardwareShortcutBinding? _bindingFromJson(Map<String, dynamic> item) {
|
|||||||
final action = _actionFromBackend(item['shortcutKey']?.toString());
|
final action = _actionFromBackend(item['shortcutKey']?.toString());
|
||||||
final rawCode = item['buttonCode'];
|
final rawCode = item['buttonCode'];
|
||||||
final enabled = item['enabled'] != false;
|
final enabled = item['enabled'] != false;
|
||||||
final code =
|
final code = rawCode is int ? rawCode : int.tryParse(rawCode?.toString() ?? '');
|
||||||
rawCode is int ? rawCode : int.tryParse(rawCode?.toString() ?? '');
|
|
||||||
if (action == null || code == null || code <= 0) return null;
|
if (action == null || code == null || code <= 0) return null;
|
||||||
return HardwareShortcutBinding(
|
return HardwareShortcutBinding(
|
||||||
action: action,
|
action: action,
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
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,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:battery_plus/battery_plus.dart';
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
@ -11,7 +10,6 @@ import 'offline_queue_service.dart';
|
|||||||
class LocationReporterService {
|
class LocationReporterService {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
final OfflineQueueService _offlineQueue;
|
final OfflineQueueService _offlineQueue;
|
||||||
final Battery _battery = Battery();
|
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
|
||||||
LocationReporterService(this._apiClient, this._offlineQueue);
|
LocationReporterService(this._apiClient, this._offlineQueue);
|
||||||
@ -34,14 +32,12 @@ class LocationReporterService {
|
|||||||
try {
|
try {
|
||||||
await Geolocator.requestPermission();
|
await Geolocator.requestPermission();
|
||||||
final position = await Geolocator.getCurrentPosition();
|
final position = await Geolocator.getCurrentPosition();
|
||||||
final batteryLevel = await _readBatteryLevel();
|
|
||||||
await _apiClient.dio.post('/user/location', data: {
|
await _apiClient.dio.post('/user/location', data: {
|
||||||
'lat': position.latitude,
|
'lat': position.latitude,
|
||||||
'lng': position.longitude,
|
'lng': position.longitude,
|
||||||
'accuracy': position.accuracy,
|
'accuracy': position.accuracy,
|
||||||
'speed': position.speed,
|
'speed': position.speed,
|
||||||
'heading': position.heading,
|
'heading': position.heading,
|
||||||
if (batteryLevel != null) 'batteryLevel': batteryLevel,
|
|
||||||
});
|
});
|
||||||
} on DioException catch (_) {
|
} on DioException catch (_) {
|
||||||
await _offlineQueue.enqueue(OfflineRequest(
|
await _offlineQueue.enqueue(OfflineRequest(
|
||||||
@ -54,12 +50,4 @@ class LocationReporterService {
|
|||||||
// GPS permission can be unavailable during desktop/web testing.
|
// GPS permission can be unavailable during desktop/web testing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int?> _readBatteryLevel() async {
|
|
||||||
try {
|
|
||||||
return await _battery.batteryLevel;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,24 +6,17 @@ class SttService {
|
|||||||
final SpeechToText _stt = SpeechToText();
|
final SpeechToText _stt = SpeechToText();
|
||||||
bool _available = false;
|
bool _available = false;
|
||||||
bool _listening = false;
|
bool _listening = false;
|
||||||
bool _shouldListen = false;
|
|
||||||
bool _initializing = false;
|
|
||||||
Function(String)? onResult;
|
Function(String)? onResult;
|
||||||
|
|
||||||
Future<bool> init() async {
|
Future<bool> init() async {
|
||||||
if (_available) return true;
|
|
||||||
if (_initializing) return _available;
|
|
||||||
_initializing = true;
|
|
||||||
_available = await _stt.initialize(
|
_available = await _stt.initialize(
|
||||||
onError: (e) => _onError(e),
|
onError: (e) => _onError(e),
|
||||||
onStatus: (s) => _onStatus(s),
|
onStatus: (s) => _onStatus(s),
|
||||||
);
|
);
|
||||||
_initializing = false;
|
|
||||||
return _available;
|
return _available;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startListening() async {
|
Future<void> startListening() async {
|
||||||
_shouldListen = true;
|
|
||||||
if (!_available || _listening) return;
|
if (!_available || _listening) return;
|
||||||
_listening = true;
|
_listening = true;
|
||||||
await _stt.listen(
|
await _stt.listen(
|
||||||
@ -32,15 +25,14 @@ class SttService {
|
|||||||
onResult?.call(result.recognizedWords.toLowerCase().trim());
|
onResult?.call(result.recognizedWords.toLowerCase().trim());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
listenFor: const Duration(seconds: 60),
|
listenFor: const Duration(seconds: 10),
|
||||||
pauseFor: const Duration(seconds: 8),
|
pauseFor: const Duration(seconds: 3),
|
||||||
localeId: 'id_ID',
|
localeId: 'id_ID',
|
||||||
cancelOnError: false,
|
cancelOnError: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopListening() async {
|
Future<void> stopListening() async {
|
||||||
_shouldListen = false;
|
|
||||||
_listening = false;
|
_listening = false;
|
||||||
await _stt.stop();
|
await _stt.stop();
|
||||||
}
|
}
|
||||||
@ -50,17 +42,15 @@ class SttService {
|
|||||||
|
|
||||||
void _onError(dynamic error) {
|
void _onError(dynamic error) {
|
||||||
_listening = false;
|
_listening = false;
|
||||||
if (_shouldListen) {
|
// Auto-restart setelah error
|
||||||
Future.delayed(const Duration(seconds: 2), startListening);
|
Future.delayed(const Duration(seconds: 1), startListening);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onStatus(String status) {
|
void _onStatus(String status) {
|
||||||
if (status == 'done' || status == 'notListening') {
|
if (status == 'done' || status == 'notListening') {
|
||||||
_listening = false;
|
_listening = false;
|
||||||
if (_shouldListen) {
|
// Auto-restart agar selalu mendengarkan
|
||||||
Future.delayed(const Duration(seconds: 2), startListening);
|
Future.delayed(const Duration(milliseconds: 500), startListening);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,15 +4,9 @@ class TtsService {
|
|||||||
final FlutterTts _tts = FlutterTts();
|
final FlutterTts _tts = FlutterTts();
|
||||||
final List<String> _queue = [];
|
final List<String> _queue = [];
|
||||||
bool _speaking = false;
|
bool _speaking = false;
|
||||||
bool _initialized = false;
|
|
||||||
String _lastSpoken = '';
|
String _lastSpoken = '';
|
||||||
DateTime _lastQueuedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
|
||||||
|
|
||||||
Future<void> init(
|
Future<void> init({String language = 'id-ID', double pitch = 1.0, double rate = 0.5}) async {
|
||||||
{String language = 'id-ID',
|
|
||||||
double pitch = 1.0,
|
|
||||||
double rate = 0.5}) async {
|
|
||||||
if (_initialized) return;
|
|
||||||
await _tts.setLanguage(language);
|
await _tts.setLanguage(language);
|
||||||
await _tts.setPitch(pitch);
|
await _tts.setPitch(pitch);
|
||||||
await _tts.setSpeechRate(rate);
|
await _tts.setSpeechRate(rate);
|
||||||
@ -21,25 +15,11 @@ class TtsService {
|
|||||||
_speaking = false;
|
_speaking = false;
|
||||||
_processQueue();
|
_processQueue();
|
||||||
});
|
});
|
||||||
_initialized = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tambah ke antrian - tidak memotong yg sedang bicara
|
/// Tambah ke antrian - tidak memotong yg sedang bicara
|
||||||
void speak(String text) {
|
void speak(String text) {
|
||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
if (!_initialized) {
|
|
||||||
init().then((_) => speak(text));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final now = DateTime.now();
|
|
||||||
if (text == _lastSpoken &&
|
|
||||||
now.difference(_lastQueuedAt) < const Duration(milliseconds: 900)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_lastQueuedAt = now;
|
|
||||||
if (_queue.length >= 3) {
|
|
||||||
_queue.removeAt(0);
|
|
||||||
}
|
|
||||||
_queue.add(text);
|
_queue.add(text);
|
||||||
if (!_speaking) _processQueue();
|
if (!_speaking) _processQueue();
|
||||||
}
|
}
|
||||||
@ -47,7 +27,6 @@ class TtsService {
|
|||||||
/// Potong TTS sekarang dan langsung ucapkan ini - untuk obstacle alert
|
/// Potong TTS sekarang dan langsung ucapkan ini - untuk obstacle alert
|
||||||
Future<void> speakImmediate(String text) async {
|
Future<void> speakImmediate(String text) async {
|
||||||
if (text.isEmpty) return;
|
if (text.isEmpty) return;
|
||||||
await init();
|
|
||||||
_queue.clear();
|
_queue.clear();
|
||||||
await _tts.stop();
|
await _tts.stop();
|
||||||
_speaking = true;
|
_speaking = true;
|
||||||
@ -64,20 +43,9 @@ class TtsService {
|
|||||||
String get lastSpoken => _lastSpoken;
|
String get lastSpoken => _lastSpoken;
|
||||||
bool get isSpeaking => _speaking;
|
bool get isSpeaking => _speaking;
|
||||||
|
|
||||||
Future<void> setLanguage(String lang) async {
|
Future<void> setLanguage(String lang) async => _tts.setLanguage(lang);
|
||||||
await init(language: lang);
|
Future<void> setPitch(double pitch) async => _tts.setPitch(pitch);
|
||||||
await _tts.setLanguage(lang);
|
Future<void> setRate(double rate) async => _tts.setSpeechRate(rate);
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setPitch(double pitch) async {
|
|
||||||
await init();
|
|
||||||
await _tts.setPitch(pitch);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setRate(double rate) async {
|
|
||||||
await init();
|
|
||||||
await _tts.setSpeechRate(rate);
|
|
||||||
}
|
|
||||||
|
|
||||||
void repeatLast() {
|
void repeatLast() {
|
||||||
if (_lastSpoken.isNotEmpty) speak(_lastSpoken);
|
if (_lastSpoken.isNotEmpty) speak(_lastSpoken);
|
||||||
|
|||||||
@ -19,8 +19,6 @@ class VoiceCommand {
|
|||||||
/// Callback yang dipanggil saat command terdeteksi
|
/// Callback yang dipanggil saat command terdeteksi
|
||||||
/// Registered oleh router/screen yang relevan
|
/// Registered oleh router/screen yang relevan
|
||||||
typedef CommandCallback = void Function(VoiceCommandKey key);
|
typedef CommandCallback = void Function(VoiceCommandKey key);
|
||||||
typedef CommandRouter = void Function(String route);
|
|
||||||
typedef CommandAction = void Function();
|
|
||||||
|
|
||||||
class VoiceCommandHandler {
|
class VoiceCommandHandler {
|
||||||
final SttService _stt;
|
final SttService _stt;
|
||||||
@ -28,19 +26,9 @@ class VoiceCommandHandler {
|
|||||||
|
|
||||||
List<VoiceCommand> _commands = [];
|
List<VoiceCommand> _commands = [];
|
||||||
CommandCallback? onCommand;
|
CommandCallback? onCommand;
|
||||||
CommandRouter? _router;
|
|
||||||
final Map<VoiceCommandKey, CommandAction> _actions = {};
|
|
||||||
|
|
||||||
VoiceCommandHandler(this._stt, this._tts);
|
VoiceCommandHandler(this._stt, this._tts);
|
||||||
|
|
||||||
void registerRouter(CommandRouter router) {
|
|
||||||
_router = router;
|
|
||||||
}
|
|
||||||
|
|
||||||
void registerAction(VoiceCommandKey key, CommandAction action) {
|
|
||||||
_actions[key] = action;
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadCommands(List<VoiceCommand> commands) {
|
void loadCommands(List<VoiceCommand> commands) {
|
||||||
_commands = commands;
|
_commands = commands;
|
||||||
_stt.onResult = _processText;
|
_stt.onResult = _processText;
|
||||||
@ -78,28 +66,9 @@ class VoiceCommandHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleCommand(VoiceCommandKey key) {
|
void _handleCommand(VoiceCommandKey key) {
|
||||||
_routeFor(key);
|
|
||||||
_actions[key]?.call();
|
|
||||||
onCommand?.call(key);
|
onCommand?.call(key);
|
||||||
// Built-in actions for TTS-only commands
|
// Built-in actions for TTS-only commands
|
||||||
if (key == VoiceCommandKey.repeatLast) _tts.repeatLast();
|
if (key == VoiceCommandKey.repeatLast) _tts.repeatLast();
|
||||||
if (key == VoiceCommandKey.stopTts) _tts.stop();
|
if (key == VoiceCommandKey.stopTts) _tts.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _routeFor(VoiceCommandKey key) {
|
|
||||||
final route = switch (key) {
|
|
||||||
VoiceCommandKey.openWalkguide || VoiceCommandKey.startWalkguide =>
|
|
||||||
'/user/walkguide',
|
|
||||||
VoiceCommandKey.openNotification => '/user/notifications',
|
|
||||||
VoiceCommandKey.openSos || VoiceCommandKey.sendSos => '/user/sos',
|
|
||||||
VoiceCommandKey.openActivity => '/user/activity',
|
|
||||||
VoiceCommandKey.openNavigation => '/user/navigation',
|
|
||||||
VoiceCommandKey.openSettings => '/user/settings',
|
|
||||||
VoiceCommandKey.callGuardian => '/call',
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
if (route != null) {
|
|
||||||
_router?.call(route);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,9 +13,9 @@ import '../storage/secure_storage.dart';
|
|||||||
/// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user.
|
/// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user.
|
||||||
///
|
///
|
||||||
/// Subscriptions yang dipakai:
|
/// Subscriptions yang dipakai:
|
||||||
/// Guardian → /topic/location/{userId} live GPS update
|
/// Guardian → /topic/location/{userId} live GPS update
|
||||||
/// Guardian → /queue/sos/{guardianId} SOS alert real-time
|
/// Guardian → /queue/sos/{guardianId} SOS alert real-time
|
||||||
/// User → /queue/notif/{userId} notifikasi dari Guardian
|
/// User → /queue/notif/{userId} notifikasi dari Guardian
|
||||||
class WebSocketService {
|
class WebSocketService {
|
||||||
final SecureStorage _storage;
|
final SecureStorage _storage;
|
||||||
|
|
||||||
@ -26,13 +26,11 @@ class WebSocketService {
|
|||||||
void Function(double lat, double lng)? _onLocation;
|
void Function(double lat, double lng)? _onLocation;
|
||||||
void Function(Map<String, dynamic> sosData)? _onSos;
|
void Function(Map<String, dynamic> sosData)? _onSos;
|
||||||
void Function(Map<String, dynamic> notifData)? _onNotif;
|
void Function(Map<String, dynamic> notifData)? _onNotif;
|
||||||
void Function(Map<String, dynamic> callData)? _onCall;
|
|
||||||
|
|
||||||
// Subscription frames (untuk unsubscribe)
|
// Subscription frames (untuk unsubscribe)
|
||||||
StompUnsubscribe? _locationUnsub;
|
StompUnsubscribe? _locationUnsub;
|
||||||
StompUnsubscribe? _sosUnsub;
|
StompUnsubscribe? _sosUnsub;
|
||||||
StompUnsubscribe? _notifUnsub;
|
StompUnsubscribe? _notifUnsub;
|
||||||
StompUnsubscribe? _callUnsub;
|
|
||||||
|
|
||||||
WebSocketService(this._storage);
|
WebSocketService(this._storage);
|
||||||
|
|
||||||
@ -90,18 +88,18 @@ class WebSocketService {
|
|||||||
await completer.future.timeout(const Duration(seconds: 5));
|
await completer.future.timeout(const Duration(seconds: 5));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[WS] Connect timeout/error: $e');
|
debugPrint('[WS] Connect timeout/error: $e');
|
||||||
// Don't throw — let dashboard work without WS
|
// Don't throw — let dashboard work without WS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe ke live GPS updates dari User.
|
/// Subscribe ke live GPS updates dari User.
|
||||||
/// Guardian panggil ini setelah connect.
|
/// Guardian panggil ini setelah connect.
|
||||||
/// [userId] = ID dari ROLE_USER yang dipair.
|
/// [userId] = ID dari ROLE_USER yang dipair.
|
||||||
void subscribeLocation(
|
void subscribeLocation(String userId,
|
||||||
String userId, void Function(double lat, double lng) callback) {
|
void Function(double lat, double lng) callback) {
|
||||||
_onLocation = callback;
|
_onLocation = callback;
|
||||||
if (_client == null || !_connected) {
|
if (_client == null || !_connected) {
|
||||||
debugPrint('[WS] subscribeLocation skipped — not connected');
|
debugPrint('[WS] subscribeLocation skipped — not connected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_locationUnsub?.call(); // unsubscribe sebelumnya jika ada
|
_locationUnsub?.call(); // unsubscribe sebelumnya jika ada
|
||||||
@ -109,7 +107,8 @@ class WebSocketService {
|
|||||||
destination: '/topic/location/$userId',
|
destination: '/topic/location/$userId',
|
||||||
callback: (frame) {
|
callback: (frame) {
|
||||||
try {
|
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 lat = (data['lat'] as num?)?.toDouble();
|
||||||
final lng = (data['lng'] as num?)?.toDouble();
|
final lng = (data['lng'] as num?)?.toDouble();
|
||||||
if (lat != null && lng != null) {
|
if (lat != null && lng != null) {
|
||||||
@ -136,7 +135,8 @@ class WebSocketService {
|
|||||||
destination: '/queue/sos/$guardianId',
|
destination: '/queue/sos/$guardianId',
|
||||||
callback: (frame) {
|
callback: (frame) {
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
final data =
|
||||||
|
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||||
_onSos?.call(data);
|
_onSos?.call(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[WS] SOS parse error: $e');
|
debugPrint('[WS] SOS parse error: $e');
|
||||||
@ -147,7 +147,7 @@ class WebSocketService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe ke notifikasi Guardian → User.
|
/// Subscribe ke notifikasi Guardian → User.
|
||||||
/// [userId] = ID dari ROLE_USER yang login.
|
/// [userId] = ID dari ROLE_USER yang login.
|
||||||
void subscribeNotification(
|
void subscribeNotification(
|
||||||
void Function(Map<String, dynamic> notifData) callback) {
|
void Function(Map<String, dynamic> notifData) callback) {
|
||||||
@ -161,7 +161,8 @@ class WebSocketService {
|
|||||||
destination: '/queue/notif/$userId',
|
destination: '/queue/notif/$userId',
|
||||||
callback: (frame) {
|
callback: (frame) {
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
final data =
|
||||||
|
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||||
_onNotif?.call(data);
|
_onNotif?.call(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[WS] Notif parse error: $e');
|
debugPrint('[WS] Notif parse error: $e');
|
||||||
@ -172,46 +173,20 @@ class WebSocketService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe ke panggilan masuk realtime. Ini melengkapi FCM agar call tetap
|
|
||||||
/// masuk saat app foreground atau ketika FCM di app clone tidak stabil.
|
|
||||||
void subscribeCall(void Function(Map<String, dynamic> callData) callback) {
|
|
||||||
_onCall = callback;
|
|
||||||
if (_client == null || !_connected) return;
|
|
||||||
|
|
||||||
_storage.getUserId().then((userId) {
|
|
||||||
if (userId == null) return;
|
|
||||||
_callUnsub?.call();
|
|
||||||
_callUnsub = _client!.subscribe(
|
|
||||||
destination: '/queue/call/$userId',
|
|
||||||
callback: (frame) {
|
|
||||||
try {
|
|
||||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
|
||||||
_onCall?.call(data);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[WS] Call parse error: $e');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
debugPrint('[WS] Subscribed to /queue/call/$userId');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disconnect dan cleanup semua subscriptions.
|
/// Disconnect dan cleanup semua subscriptions.
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
_locationUnsub?.call();
|
_locationUnsub?.call();
|
||||||
_sosUnsub?.call();
|
_sosUnsub?.call();
|
||||||
_notifUnsub?.call();
|
_notifUnsub?.call();
|
||||||
_callUnsub?.call();
|
|
||||||
_locationUnsub = null;
|
_locationUnsub = null;
|
||||||
_sosUnsub = null;
|
_sosUnsub = null;
|
||||||
_notifUnsub = null;
|
_notifUnsub = null;
|
||||||
_callUnsub = null;
|
|
||||||
_client?.deactivate();
|
_client?.deactivate();
|
||||||
_client = null;
|
_client = null;
|
||||||
_connected = false;
|
_connected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy compat — lama pakai onMessage raw
|
// Legacy compat — lama pakai onMessage raw
|
||||||
void send(Object message) {
|
void send(Object message) {
|
||||||
debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.');
|
debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class AppColors {
|
class AppColors {
|
||||||
static const primaryBlue = Color(0xFF4A90D9);
|
static const primary = Color(0xFF1A56DB);
|
||||||
static const softBlueBg = Color(0xFFEBF4FF);
|
|
||||||
static const softPinkBg = Color(0xFFFFF0F5);
|
|
||||||
static const softYellowBg = Color(0xFFFFFBEB);
|
|
||||||
static const cardWhite = Color(0xFFFFFFFF);
|
|
||||||
static const textDark = Color(0xFF2D3748);
|
|
||||||
static const textMuted = Color(0xFFA0AEC0);
|
|
||||||
static const gold = Color(0xFFF6C90E);
|
|
||||||
static const gradientBlueStart = Color(0xFF6BB8FF);
|
|
||||||
static const gradientBlueEnd = Color(0xFF4A90D9);
|
|
||||||
static const gradientPinkStart = Color(0xFFFFB3C6);
|
|
||||||
static const gradientPinkEnd = Color(0xFFFF6B9D);
|
|
||||||
|
|
||||||
static const primary = primaryBlue;
|
|
||||||
static const primaryDark = Color(0xFF256FB8);
|
|
||||||
static const accent = Color(0xFFFF6B9D);
|
|
||||||
static const warning = Color(0xFFD97706);
|
|
||||||
static const danger = Color(0xFFDC2626);
|
static const danger = Color(0xFFDC2626);
|
||||||
static const success = Color(0xFF059669);
|
static const success = Color(0xFF16A34A);
|
||||||
static const surface = softBlueBg;
|
static const surface = Color(0xFFF8FAFC);
|
||||||
static const surfaceRaised = cardWhite;
|
static const text = Color(0xFF0F172A);
|
||||||
static const text = textDark;
|
static const muted = Color(0xFF64748B);
|
||||||
static const muted = textMuted;
|
|
||||||
static const border = Color(0xFFE2E8F0);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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,9 +8,6 @@ import '../../app/injection_container.dart';
|
|||||||
import '../../core/errors/friendly_error.dart';
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../core/theme/app_decorations.dart';
|
|
||||||
import '../../core/theme/app_text_styles.dart';
|
|
||||||
import '../../shared/widgets/animations/animations.dart';
|
|
||||||
|
|
||||||
Dio get _api => sl<ApiClient>().dio;
|
Dio get _api => sl<ApiClient>().dio;
|
||||||
|
|
||||||
@ -106,16 +103,7 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
return SafeArea(
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [AppColors.softBlueBg, Colors.white],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: FadeSlideWrapper(
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -130,7 +118,10 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Activity Log',
|
'Activity Log',
|
||||||
style: AppTextStyles.heading,
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.headlineSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.w800),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${_items.length} aktivitas tercatat',
|
'${_items.length} aktivitas tercatat',
|
||||||
@ -164,17 +155,7 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
|||||||
onSelected: (_) {
|
onSelected: (_) {
|
||||||
setState(() => _applyFilter(f));
|
setState(() => _applyFilter(f));
|
||||||
},
|
},
|
||||||
selectedColor:
|
selectedColor: AppColors.primary.withValues(alpha: 0.15),
|
||||||
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,
|
checkmarkColor: AppColors.primary,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: selected ? AppColors.primary : AppColors.muted,
|
color: selected ? AppColors.primary : AppColors.muted,
|
||||||
@ -199,23 +180,16 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
|||||||
? _EmptyPanel(filter: _selectedFilter)
|
? _EmptyPanel(filter: _selectedFilter)
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: _load,
|
onRefresh: _load,
|
||||||
child: ListView(
|
child: ListView.builder(
|
||||||
children: [
|
itemCount: _filtered.length,
|
||||||
StaggerWrapper(
|
itemBuilder: (ctx, i) =>
|
||||||
children: [
|
_LogCard(item: _filtered[i]),
|
||||||
for (final item in _filtered)
|
|
||||||
_LogCard(item: item),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -254,15 +228,7 @@ class _LogCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final meta = _logMeta(item.logType);
|
final meta = _logMeta(item.logType);
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
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(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -274,10 +240,15 @@ class _LogCard extends StatelessWidget {
|
|||||||
height: 36,
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: meta.color.withValues(alpha: 0.12),
|
color: meta.color.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(50),
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
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),
|
const SizedBox(width: 12),
|
||||||
@ -307,8 +278,7 @@ class _LogCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (item.description != null &&
|
if (item.description != null && item.description!.isNotEmpty)
|
||||||
item.description!.isNotEmpty)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 2),
|
padding: const EdgeInsets.only(top: 2),
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -324,7 +294,6 @@ class _LogCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -425,13 +394,6 @@ class _ErrorPanel extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.cardWhite,
|
|
||||||
borderRadius: AppDecorations.cardRadius,
|
|
||||||
boxShadow: AppDecorations.cardShadow,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@ -448,7 +410,6 @@ class _ErrorPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -460,13 +421,6 @@ class _EmptyPanel extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppColors.cardWhite,
|
|
||||||
borderRadius: AppDecorations.cardRadius,
|
|
||||||
boxShadow: AppDecorations.cardShadow,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@ -483,7 +437,6 @@ class _EmptyPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
Activity log application layer.
|
|
||||||
|
|
||||||
This layer is reserved for Cubit/BLoC orchestration between the activity log UI and domain contracts.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Activity log data layer.
|
|
||||||
|
|
||||||
This layer is reserved for remote/local data sources and repository implementations.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Activity log domain layer.
|
|
||||||
|
|
||||||
This layer is reserved for entities, repository contracts, and use cases.
|
|
||||||
@ -9,11 +9,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/ai/detection_export.dart';
|
import '../../core/ai/detection_export.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
|
||||||
import '../../core/theme/app_decorations.dart';
|
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
import '../../core/utils/operation_guard.dart';
|
import '../../core/utils/operation_guard.dart';
|
||||||
import '../../shared/widgets/animations/animations.dart';
|
|
||||||
import '../../shared/widgets/feature_page.dart';
|
import '../../shared/widgets/feature_page.dart';
|
||||||
|
|
||||||
class AiBenchmarkScreen extends StatefulWidget {
|
class AiBenchmarkScreen extends StatefulWidget {
|
||||||
@ -128,12 +125,8 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
|||||||
enableAudio: false,
|
enableAudio: false,
|
||||||
);
|
);
|
||||||
controller = activeController;
|
controller = activeController;
|
||||||
await activeController
|
await activeController.initialize().timeout(const Duration(seconds: 5));
|
||||||
.initialize()
|
await activeController.takePicture().timeout(const Duration(seconds: 5));
|
||||||
.timeout(const Duration(seconds: 5));
|
|
||||||
await activeController
|
|
||||||
.takePicture()
|
|
||||||
.timeout(const Duration(seconds: 5));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (_) {},
|
onError: (_) {},
|
||||||
@ -205,11 +198,7 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
|||||||
label: const Text('Clear log'),
|
label: const Text('Clear log'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
StaggerWrapper(
|
|
||||||
children: [
|
|
||||||
for (final run in _runs) _BenchmarkCard(run: run),
|
for (final run in _runs) _BenchmarkCard(run: run),
|
||||||
],
|
|
||||||
),
|
|
||||||
if (_runs.isEmpty)
|
if (_runs.isEmpty)
|
||||||
const FeatureEmptyPanel(
|
const FeatureEmptyPanel(
|
||||||
icon: Icons.speed,
|
icon: Icons.speed,
|
||||||
@ -235,10 +224,9 @@ class _BenchmarkCard extends StatelessWidget {
|
|||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.cardWhite,
|
color: Colors.white,
|
||||||
borderRadius: AppDecorations.cardRadius,
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
boxShadow: AppDecorations.cardShadow,
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -274,8 +262,7 @@ class _StatusBox extends StatelessWidget {
|
|||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
|
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
|
||||||
borderRadius: AppDecorations.cardRadius,
|
borderRadius: BorderRadius.circular(14),
|
||||||
boxShadow: AppDecorations.cardShadow,
|
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
AI benchmark application layer.
|
|
||||||
|
|
||||||
This layer is reserved for benchmark Cubit/BLoC orchestration.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
AI benchmark data layer.
|
|
||||||
|
|
||||||
This layer is reserved for benchmark result persistence and export adapters.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
AI benchmark domain layer.
|
|
||||||
|
|
||||||
This layer is reserved for benchmark entities and use cases.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Auth application layer.
|
|
||||||
|
|
||||||
This layer is reserved for auth Cubit/BLoC orchestration between auth UI and auth domain contracts.
|
|
||||||
@ -8,20 +8,14 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../../app/app_cubit.dart';
|
import '../../app/app_cubit.dart';
|
||||||
import '../../app/router.dart';
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/errors/friendly_error.dart';
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/services/fcm_service.dart';
|
|
||||||
import '../../core/services/incoming_call_polling_service.dart';
|
|
||||||
import '../../core/services/offline_queue_service.dart';
|
import '../../core/services/offline_queue_service.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
import '../../core/services/websocket_service.dart';
|
import '../../core/services/websocket_service.dart';
|
||||||
import '../../core/storage/secure_storage.dart';
|
import '../../core/storage/secure_storage.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
|
||||||
import '../../core/theme/app_decorations.dart';
|
|
||||||
import '../../core/theme/app_text_styles.dart';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LoginScreen
|
// LoginScreen
|
||||||
@ -82,8 +76,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
},
|
},
|
||||||
onError: (message) => _snack(context, message),
|
onError: (message) => _snack(context, message),
|
||||||
fallback: 'Login gagal. Periksa email dan password kamu.',
|
fallback: 'Login gagal. Periksa email dan password kamu.',
|
||||||
connectionHint:
|
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
|
||||||
'Tidak bisa ke server. Cek URL backend di Connect Server dan pastikan server aktif.',
|
|
||||||
);
|
);
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
@ -154,36 +147,46 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.softBlueBg,
|
backgroundColor: const Color(0xFFEAF4FF),
|
||||||
body: LayoutBuilder(
|
body: Stack(
|
||||||
builder: (context, constraints) {
|
children: [
|
||||||
final compact =
|
const Positioned.fill(
|
||||||
constraints.maxWidth < 480 || constraints.maxHeight < 720;
|
child: DecoratedBox(
|
||||||
return DecoratedBox(
|
decoration: BoxDecoration(
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
|
||||||
AppColors.softBlueBg,
|
|
||||||
Colors.white,
|
|
||||||
AppColors.softPinkBg,
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
),
|
||||||
child: Center(
|
),
|
||||||
|
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(
|
child: SingleChildScrollView(
|
||||||
keyboardDismissBehavior:
|
padding: const EdgeInsets.all(24),
|
||||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
compact ? 14 : 24,
|
|
||||||
compact ? 12 : 24,
|
|
||||||
compact ? 14 : 24,
|
|
||||||
20 + MediaQuery.of(context).viewInsets.bottom,
|
|
||||||
),
|
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: compact ? 380 : 430),
|
constraints: const BoxConstraints(maxWidth: 430),
|
||||||
child: TweenAnimationBuilder<double>(
|
child: TweenAnimationBuilder<double>(
|
||||||
tween: Tween(begin: 18, end: 0),
|
tween: Tween(begin: 18, end: 0),
|
||||||
duration: const Duration(milliseconds: 520),
|
duration: const Duration(milliseconds: 520),
|
||||||
@ -198,104 +201,69 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.cardWhite,
|
color: Colors.white.withValues(alpha: 0.96),
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(30),
|
||||||
BorderRadius.circular(compact ? 22 : 28),
|
border: Border.all(
|
||||||
boxShadow: AppDecorations.cardShadow,
|
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: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
|
||||||
compact ? 18 : 24,
|
|
||||||
compact ? 18 : 26,
|
|
||||||
compact ? 18 : 24,
|
|
||||||
compact ? 18 : 24,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: compact ? 44 : 56,
|
width: 56,
|
||||||
height: compact ? 44 : 56,
|
height: 56,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppDecorations.blueGradient,
|
color: const Color(0xFF1D4ED8),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(18),
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: Color(0x334A90D9),
|
|
||||||
blurRadius: 18,
|
|
||||||
offset: Offset(0, 8),
|
|
||||||
),
|
),
|
||||||
],
|
child: const Icon(Icons.navigation_rounded,
|
||||||
),
|
color: Colors.white, size: 30),
|
||||||
child: Icon(Icons.navigation_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: compact ? 26 : 30),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'WalkGuide',
|
'WalkGuide',
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w900,
|
||||||
color: AppColors.textDark,
|
color: Color(0xFF0F172A),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: compact ? 14 : 16),
|
const SizedBox(height: 22),
|
||||||
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(
|
Text(
|
||||||
title,
|
title,
|
||||||
maxLines: 2,
|
style: Theme.of(context)
|
||||||
overflow: TextOverflow.ellipsis,
|
.textTheme
|
||||||
style: AppTextStyles.heading.copyWith(
|
.headlineMedium
|
||||||
fontSize: compact ? 26 : null,
|
?.copyWith(
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF0F172A),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.muted,
|
color: Color(0xFF64748B),
|
||||||
height: 1.35,
|
height: 1.35,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: compact ? 18 : 26),
|
const SizedBox(height: 26),
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -306,9 +274,7 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -345,16 +311,9 @@ Future<void> _saveAuthAndRoute(
|
|||||||
|
|
||||||
void _startPostLoginServices(String serverUrl) {
|
void _startPostLoginServices(String serverUrl) {
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
sl<IncomingCallPollingService>().start();
|
await sl<WebSocketService>()
|
||||||
await sl<FcmService>().init().timeout(const Duration(seconds: 4));
|
.connect(serverUrl)
|
||||||
final ws = sl<WebSocketService>();
|
.timeout(const Duration(seconds: 2));
|
||||||
await ws.connect(serverUrl).timeout(const Duration(seconds: 2));
|
|
||||||
ws.subscribeCall((data) {
|
|
||||||
final type = data['type']?.toString();
|
|
||||||
if (type == 'INCOMING_CALL') {
|
|
||||||
appRouter.go('/incoming-call', extra: data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await sl<OfflineQueueService>()
|
await sl<OfflineQueueService>()
|
||||||
.syncPending(sl<ApiClient>())
|
.syncPending(sl<ApiClient>())
|
||||||
.timeout(const Duration(seconds: 3));
|
.timeout(const Duration(seconds: 3));
|
||||||
|
|||||||
@ -7,10 +7,6 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/errors/friendly_error.dart';
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
|
||||||
import '../../core/theme/app_decorations.dart';
|
|
||||||
import '../../core/theme/app_text_styles.dart';
|
|
||||||
import '../../shared/widgets/animations/animations.dart';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// RegisterScreen
|
// RegisterScreen
|
||||||
@ -73,8 +69,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
},
|
},
|
||||||
onError: (message) => _snack(context, message),
|
onError: (message) => _snack(context, message),
|
||||||
fallback: 'Registrasi gagal. Periksa data akun kamu.',
|
fallback: 'Registrasi gagal. Periksa data akun kamu.',
|
||||||
connectionHint:
|
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
|
||||||
'Tidak bisa ke server. Cek URL backend di Connect Server dan pastikan server aktif.',
|
|
||||||
);
|
);
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
@ -133,7 +128,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _role == 'USER'
|
color: _role == 'USER'
|
||||||
? AppColors.softBlueBg
|
? const Color(0xFFEFF6FF)
|
||||||
: const Color(0xFFF0FDF4),
|
: const Color(0xFFF0FDF4),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
@ -239,19 +234,18 @@ class _RoleCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BounceTap(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? AppColors.softBlueBg : Colors.white,
|
color: selected ? const Color(0xFFEFF6FF) : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: selected ? AppColors.primaryBlue : AppColors.border,
|
color: selected ? const Color(0xFF1A56DB) : const Color(0xFFE2E8F0),
|
||||||
width: selected ? 2 : 1,
|
width: selected ? 2 : 1,
|
||||||
),
|
),
|
||||||
boxShadow: selected ? AppDecorations.cardShadow : null,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@ -259,9 +253,10 @@ class _RoleCard extends StatelessWidget {
|
|||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: selected
|
||||||
selected ? AppColors.primaryBlue : const Color(0xFFF1F5F9),
|
? const Color(0xFF1A56DB)
|
||||||
borderRadius: BorderRadius.circular(50),
|
: const Color(0xFFF1F5F9),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(icon,
|
child: Icon(icon,
|
||||||
color: selected ? Colors.white : const Color(0xFF64748B)),
|
color: selected ? Colors.white : const Color(0xFF64748B)),
|
||||||
@ -272,16 +267,16 @@ class _RoleCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(title,
|
Text(title,
|
||||||
style: AppTextStyles.subheading.copyWith(fontSize: 16)),
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w800, fontSize: 16)),
|
||||||
Text(subtitle,
|
Text(subtitle,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.muted, fontSize: 13)),
|
color: Color(0xFF64748B), fontSize: 13)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (selected)
|
if (selected)
|
||||||
const Icon(Icons.check_circle_rounded,
|
const Icon(Icons.check_circle_rounded, color: Color(0xFF1A56DB)),
|
||||||
color: AppColors.primaryBlue),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -303,36 +298,46 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.softBlueBg,
|
backgroundColor: const Color(0xFFEAF4FF),
|
||||||
body: LayoutBuilder(
|
body: Stack(
|
||||||
builder: (context, constraints) {
|
children: [
|
||||||
final compact =
|
const Positioned.fill(
|
||||||
constraints.maxWidth < 480 || constraints.maxHeight < 720;
|
child: DecoratedBox(
|
||||||
return DecoratedBox(
|
decoration: BoxDecoration(
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
|
||||||
AppColors.softBlueBg,
|
|
||||||
Colors.white,
|
|
||||||
AppColors.softPinkBg,
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
),
|
||||||
child: Center(
|
),
|
||||||
|
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(
|
child: SingleChildScrollView(
|
||||||
keyboardDismissBehavior:
|
padding: const EdgeInsets.all(24),
|
||||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
compact ? 14 : 24,
|
|
||||||
compact ? 12 : 24,
|
|
||||||
compact ? 14 : 24,
|
|
||||||
20 + MediaQuery.of(context).viewInsets.bottom,
|
|
||||||
),
|
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: compact ? 380 : 430),
|
constraints: const BoxConstraints(maxWidth: 430),
|
||||||
child: TweenAnimationBuilder<double>(
|
child: TweenAnimationBuilder<double>(
|
||||||
tween: Tween(begin: 18, end: 0),
|
tween: Tween(begin: 18, end: 0),
|
||||||
duration: const Duration(milliseconds: 520),
|
duration: const Duration(milliseconds: 520),
|
||||||
@ -347,77 +352,69 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.cardWhite,
|
color: Colors.white.withValues(alpha: 0.96),
|
||||||
borderRadius:
|
borderRadius: BorderRadius.circular(30),
|
||||||
BorderRadius.circular(compact ? 22 : 28),
|
border: Border.all(
|
||||||
boxShadow: AppDecorations.cardShadow,
|
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: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
|
||||||
compact ? 18 : 24,
|
|
||||||
compact ? 18 : 26,
|
|
||||||
compact ? 18 : 24,
|
|
||||||
compact ? 18 : 24,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: compact ? 44 : 56,
|
width: 56,
|
||||||
height: compact ? 44 : 56,
|
height: 56,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppDecorations.blueGradient,
|
color: const Color(0xFF1D4ED8),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(18),
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: Color(0x334A90D9),
|
|
||||||
blurRadius: 18,
|
|
||||||
offset: Offset(0, 8),
|
|
||||||
),
|
),
|
||||||
],
|
child: const Icon(Icons.navigation_rounded,
|
||||||
),
|
color: Colors.white, size: 30),
|
||||||
child: Icon(Icons.navigation_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: compact ? 26 : 30),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'WalkGuide',
|
'WalkGuide',
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w900,
|
||||||
color: AppColors.textDark,
|
color: Color(0xFF0F172A),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: compact ? 14 : 22),
|
const SizedBox(height: 22),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
maxLines: 2,
|
style: Theme.of(context)
|
||||||
overflow: TextOverflow.ellipsis,
|
.textTheme
|
||||||
style: AppTextStyles.heading.copyWith(
|
.headlineMedium
|
||||||
fontSize: compact ? 26 : null,
|
?.copyWith(
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF0F172A),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.muted,
|
color: Color(0xFF64748B),
|
||||||
height: 1.35,
|
height: 1.35,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: compact ? 18 : 26),
|
const SizedBox(height: 26),
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -428,9 +425,7 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,17 +3,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../app/router.dart';
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
|
||||||
import '../../core/errors/friendly_error.dart';
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
|
||||||
import '../../core/services/fcm_service.dart';
|
|
||||||
import '../../core/services/incoming_call_polling_service.dart';
|
|
||||||
import '../../core/services/offline_queue_service.dart';
|
|
||||||
import '../../core/services/websocket_service.dart';
|
|
||||||
import '../../core/storage/secure_storage.dart';
|
import '../../core/storage/secure_storage.dart';
|
||||||
import '../../shared/widgets/walkguide_loading_screen.dart';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SplashScreen
|
// SplashScreen
|
||||||
@ -40,101 +32,110 @@ class SplashScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _SplashScreenState extends State<SplashScreen>
|
class _SplashScreenState extends State<SplashScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late final AnimationController _screenCtrl;
|
late final AnimationController _animCtrl;
|
||||||
late final Animation<double> _screenFade;
|
late final Animation<double> _fadeAnim;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_screenCtrl = AnimationController(
|
_animCtrl = AnimationController(
|
||||||
vsync: this,
|
vsync: this, duration: const Duration(milliseconds: 700));
|
||||||
duration: const Duration(milliseconds: 260),
|
_fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeIn);
|
||||||
value: 1,
|
_animCtrl.forward();
|
||||||
);
|
|
||||||
_screenFade = CurvedAnimation(
|
|
||||||
parent: _screenCtrl,
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
);
|
|
||||||
_route();
|
_route();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_screenCtrl.dispose();
|
_animCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _route() async {
|
Future<void> _route() async {
|
||||||
final routed = await runFriendlyAction(
|
final routed = await runFriendlyAction(
|
||||||
() async {
|
() async {
|
||||||
// Animasi logo selalu tampil minimal 900ms agar tidak langsung flash.
|
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
|
||||||
await Future.delayed(const Duration(milliseconds: 900));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
final storage = sl<SecureStorage>();
|
final storage = sl<SecureStorage>();
|
||||||
final token = await storage.getAccessToken().timeout(
|
final token =
|
||||||
const Duration(seconds: 3),
|
await storage.getAccessToken().timeout(const Duration(seconds: 3));
|
||||||
);
|
final role =
|
||||||
final role = await storage.getUserRole().timeout(
|
await storage.getUserRole().timeout(const Duration(seconds: 3));
|
||||||
const Duration(seconds: 3),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (token == null || role == null) {
|
if (token == null || role == null) {
|
||||||
await _fadeOutThenGo('/login');
|
context.go('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final serverUrl = await AppConstants.getServerUrl();
|
|
||||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
|
||||||
_startAutoLoginServices(serverUrl);
|
|
||||||
} else {
|
|
||||||
sl<IncomingCallPollingService>().start();
|
|
||||||
}
|
|
||||||
// Auto-login: arahkan ke home sesuai role.
|
// Auto-login: arahkan ke home sesuai role.
|
||||||
await _fadeOutThenGo(
|
context.go(role == 'ROLE_GUARDIAN'
|
||||||
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide',
|
? '/guardian/dashboard'
|
||||||
);
|
: '/user/walkguide');
|
||||||
},
|
},
|
||||||
onError: (_) {},
|
onError: (_) {},
|
||||||
fallback: 'Sesi belum bisa dipulihkan.',
|
fallback: 'Sesi belum bisa dipulihkan.',
|
||||||
);
|
);
|
||||||
if (!routed && mounted) await _fadeOutThenGo('/login');
|
if (!routed && mounted) context.go('/login');
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fadeOutThenGo(String route) async {
|
|
||||||
if (!mounted) return;
|
|
||||||
await _screenCtrl.reverse();
|
|
||||||
if (mounted) context.go(route);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FadeTransition(
|
return Scaffold(
|
||||||
opacity: _screenFade,
|
backgroundColor: const Color(0xFF1A56DB),
|
||||||
child: const WalkGuideLoadingScreen(
|
body: Center(
|
||||||
subtitle: 'Restoring your session',
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
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.
|
|
||||||
@ -1,34 +1,32 @@
|
|||||||
// ignore_for_file: use_build_context_synchronously
|
// ignore_for_file: use_build_context_synchronously, prefer_const_constructors
|
||||||
|
// lib/features/call/call_screen.dart
|
||||||
|
//
|
||||||
|
// CallScreen — user memanggil Guardian via Agora
|
||||||
|
// IncomingCallScreen — Guardian/User menerima panggilan masuk
|
||||||
|
//
|
||||||
|
// Keduanya pakai CallService yang sudah ada (agora_rtc_engine).
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/errors/friendly_error.dart';
|
|
||||||
import '../../core/services/call_service.dart';
|
import '../../core/services/call_service.dart';
|
||||||
import '../../core/services/haptic_service.dart';
|
import '../../core/services/haptic_service.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
import '../../core/storage/secure_storage.dart';
|
|
||||||
import '../../core/theme/app_colors.dart';
|
|
||||||
import '../../core/theme/app_decorations.dart';
|
|
||||||
import '../../shared/widgets/animations/animations.dart';
|
|
||||||
|
|
||||||
const _kBlue = AppColors.primaryBlue;
|
// ─── Colours ─────────────────────────────────────────────────────────────────
|
||||||
|
const _kBlue = Color(0xFF1A56DB);
|
||||||
const _kGreen = Color(0xFF16A34A);
|
const _kGreen = Color(0xFF16A34A);
|
||||||
const _kRed = Color(0xFFDC2626);
|
const _kRed = Color(0xFFDC2626);
|
||||||
const _kMuted = Color(0xFF64748B);
|
const _kMuted = Color(0xFF64748B);
|
||||||
const _kBg = Color(0xFF0F172A);
|
const _kBg = Color(0xFF0F172A); // dark bg untuk call screen
|
||||||
|
|
||||||
|
// ─── CallScreen ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class CallScreen extends StatefulWidget {
|
class CallScreen extends StatefulWidget {
|
||||||
final String targetLabel;
|
const CallScreen({super.key});
|
||||||
final String returnRoute;
|
|
||||||
|
|
||||||
const CallScreen({
|
|
||||||
super.key,
|
|
||||||
this.targetLabel = 'Guardian',
|
|
||||||
this.returnRoute = '/user/walkguide',
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CallScreen> createState() => _CallScreenState();
|
State<CallScreen> createState() => _CallScreenState();
|
||||||
@ -40,161 +38,64 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
bool _muted = false;
|
bool _muted = false;
|
||||||
bool _speakerOn = true;
|
bool _speakerOn = true;
|
||||||
int _secondsElapsed = 0;
|
int _secondsElapsed = 0;
|
||||||
int? _otherId;
|
|
||||||
String? _activeChannel;
|
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
Timer? _ringTimeout;
|
|
||||||
Timer? _acceptedPoll;
|
|
||||||
|
|
||||||
late final AnimationController _pulseCtrl = AnimationController(
|
// animasi pulse saat ringing
|
||||||
vsync: this,
|
late AnimationController _pulseCtrl;
|
||||||
duration: const Duration(milliseconds: 1200),
|
late Animation<double> _pulseScale;
|
||||||
)..repeat(reverse: true);
|
|
||||||
late final Animation<double> _pulseScale = Tween(
|
|
||||||
begin: 0.95,
|
|
||||||
end: 1.08,
|
|
||||||
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
sl<TtsService>().speak('Memanggil ${widget.targetLabel}.');
|
_pulseCtrl = AnimationController(
|
||||||
unawaited(_startCall());
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1200),
|
||||||
|
)..repeat(reverse: true);
|
||||||
|
_pulseScale = Tween(begin: 0.95, end: 1.08)
|
||||||
|
.animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||||
|
|
||||||
|
sl<TtsService>().speak('Memanggil Guardian.');
|
||||||
|
_startCall();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startCall() async {
|
Future<void> _startCall() async {
|
||||||
final callService = sl<CallService>();
|
final joined = await sl<CallService>().callPairedUser();
|
||||||
callService.setRemoteUserJoinedCallback(_markRemoteConnected);
|
|
||||||
callService.setRemoteUserOfflineCallback(() {
|
|
||||||
unawaited(_finishRemoteEnded());
|
|
||||||
});
|
|
||||||
|
|
||||||
final invite = await runFriendly<Map<String, dynamic>>(
|
|
||||||
() => callService.startPairedCall(),
|
|
||||||
onError: _failCall,
|
|
||||||
fallback: 'Panggilan gagal. Server tidak merespons.',
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (invite == null) {
|
|
||||||
if (_phase != _CallPhase.failed) {
|
|
||||||
_failCall('Panggilan gagal. Pastikan sudah pairing dan server aktif.');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_otherId = _asInt(invite['receiverId']);
|
if (joined) {
|
||||||
_activeChannel = invite['channelName']?.toString();
|
|
||||||
setState(() => _phase = _CallPhase.calling);
|
|
||||||
sl<TtsService>().speak(
|
|
||||||
'Panggilan dikirim ke ${widget.targetLabel}. Menunggu jawaban.',
|
|
||||||
);
|
|
||||||
_startAcceptedPolling();
|
|
||||||
_ringTimeout?.cancel();
|
|
||||||
_ringTimeout = Timer(const Duration(seconds: 35), () {
|
|
||||||
if (!mounted || _phase == _CallPhase.connected) return;
|
|
||||||
_failCall('Panggilan tidak dijawab.');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startAcceptedPolling() {
|
|
||||||
_acceptedPoll?.cancel();
|
|
||||||
_acceptedPoll = Timer.periodic(const Duration(seconds: 1), (_) async {
|
|
||||||
if (!mounted || _activeChannel == null) return;
|
|
||||||
final state = await runFriendly<Map<String, dynamic>>(
|
|
||||||
() => sl<CallService>()
|
|
||||||
.getCallState(_activeChannel)
|
|
||||||
.timeout(const Duration(seconds: 2)),
|
|
||||||
onError: (_) {},
|
|
||||||
fallback: 'Polling panggilan gagal.',
|
|
||||||
);
|
|
||||||
if (state == null) return;
|
|
||||||
final status = state['status']?.toString();
|
|
||||||
if (status == 'ENDED') {
|
|
||||||
await _finishRemoteEnded();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (status == 'ACCEPTED') {
|
|
||||||
_markRemoteConnected();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final accepted = await runFriendly<Map<String, dynamic>>(
|
|
||||||
() => sl<CallService>().getAcceptedCall().timeout(
|
|
||||||
const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
onError: (_) {},
|
|
||||||
fallback: 'Polling panggilan diterima gagal.',
|
|
||||||
);
|
|
||||||
if (accepted?['type']?.toString() != 'CALL_ACCEPTED') return;
|
|
||||||
final channel = accepted?['channelName']?.toString();
|
|
||||||
if (_activeChannel != null &&
|
|
||||||
channel != null &&
|
|
||||||
channel.isNotEmpty &&
|
|
||||||
channel != _activeChannel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_markRemoteConnected();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _markRemoteConnected() {
|
|
||||||
if (!mounted || _phase == _CallPhase.connected) return;
|
|
||||||
_acceptedPoll?.cancel();
|
|
||||||
_ringTimeout?.cancel();
|
|
||||||
setState(() => _phase = _CallPhase.connected);
|
setState(() => _phase = _CallPhase.connected);
|
||||||
sl<TtsService>().speak('Terhubung dengan ${widget.targetLabel}.');
|
sl<TtsService>().speak('Terhubung dengan Guardian.');
|
||||||
_pulseCtrl.stop();
|
_pulseCtrl.stop();
|
||||||
_startTimer();
|
_startTimer();
|
||||||
}
|
} else {
|
||||||
|
|
||||||
void _failCall(String message) {
|
|
||||||
_acceptedPoll?.cancel();
|
|
||||||
_ringTimeout?.cancel();
|
|
||||||
sl<CallService>().setRemoteUserJoinedCallback(null);
|
|
||||||
sl<CallService>().setRemoteUserOfflineCallback(null);
|
|
||||||
setState(() => _phase = _CallPhase.failed);
|
setState(() => _phase = _CallPhase.failed);
|
||||||
_pulseCtrl.stop();
|
sl<TtsService>()
|
||||||
sl<TtsService>().speak(message);
|
.speak('Panggilan gagal. Pastikan Agora sudah dikonfigurasi.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startTimer() {
|
void _startTimer() {
|
||||||
_timer?.cancel();
|
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
if (mounted) setState(() => _secondsElapsed++);
|
if (mounted) setState(() => _secondsElapsed++);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _finishRemoteEnded() async {
|
|
||||||
if (!mounted) return;
|
|
||||||
_timer?.cancel();
|
|
||||||
_ringTimeout?.cancel();
|
|
||||||
_acceptedPoll?.cancel();
|
|
||||||
await sl<CallService>().leave();
|
|
||||||
sl<TtsService>().speak('Panggilan diakhiri oleh lawan bicara.');
|
|
||||||
if (mounted) context.go(widget.returnRoute);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _endCall() async {
|
Future<void> _endCall() async {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_ringTimeout?.cancel();
|
await sl<CallService>().leave();
|
||||||
_acceptedPoll?.cancel();
|
|
||||||
final callService = sl<CallService>();
|
|
||||||
callService.setRemoteUserJoinedCallback(null);
|
|
||||||
callService.setRemoteUserOfflineCallback(null);
|
|
||||||
await callService.endCall(_otherId, channelName: _activeChannel);
|
|
||||||
await callService.leave();
|
|
||||||
sl<TtsService>().speak('Panggilan diakhiri.');
|
sl<TtsService>().speak('Panggilan diakhiri.');
|
||||||
if (mounted) context.go(widget.returnRoute);
|
if (mounted) context.go('/user/walkguide');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleMute() async {
|
Future<void> _toggleMute() async {
|
||||||
setState(() => _muted = !_muted);
|
setState(() => _muted = !_muted);
|
||||||
await sl<CallService>().setMuted(_muted);
|
// Agora engine mute via CallService jika ada — di sini cukup state lokal
|
||||||
|
// sl<CallService>().muteLocalAudio(_muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleSpeaker() async {
|
void _toggleSpeaker() {
|
||||||
setState(() => _speakerOn = !_speakerOn);
|
setState(() => _speakerOn = !_speakerOn);
|
||||||
await sl<CallService>().setSpeakerEnabled(_speakerOn);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _timerLabel {
|
String get _timerLabel {
|
||||||
@ -206,44 +107,76 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_ringTimeout?.cancel();
|
|
||||||
_acceptedPoll?.cancel();
|
|
||||||
sl<CallService>().setRemoteUserJoinedCallback(null);
|
|
||||||
sl<CallService>().setRemoteUserOfflineCallback(null);
|
|
||||||
_pulseCtrl.dispose();
|
_pulseCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _CallScaffold(
|
return Scaffold(
|
||||||
title: 'Panggilan',
|
backgroundColor: _kBg,
|
||||||
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// ── top bar ──────────────────────────────────────────────────
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => context.go('/user/walkguide'),
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new,
|
||||||
|
color: Colors.white54),
|
||||||
|
),
|
||||||
|
const Expanded(
|
||||||
|
child: Text('Panggilan',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 48), // balance
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|
||||||
|
// ── avatar + name ────────────────────────────────────────────
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: _pulseCtrl,
|
animation: _pulseCtrl,
|
||||||
builder: (_, child) => Transform.scale(
|
builder: (_, child) => Transform.scale(
|
||||||
scale: _phase == _CallPhase.calling ? _pulseScale.value : 1.0,
|
scale: _phase == _CallPhase.calling ? _pulseScale.value : 1.0,
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
child: _Avatar(
|
child: Container(
|
||||||
icon: Icons.shield_outlined,
|
width: 120,
|
||||||
color: _phase == _CallPhase.failed ? _kRed : _kBlue,
|
height: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: _kBlue.withValues(alpha: 0.2),
|
||||||
|
border: Border.all(color: _kBlue, width: 3),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.shield_outlined,
|
||||||
|
color: Colors.white, size: 56),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
|
||||||
widget.targetLabel,
|
const Text('Guardian',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 30,
|
fontSize: 26,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800)),
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
_PhaseLabel(phase: _phase, timerLabel: _timerLabel),
|
_PhaseLabel(phase: _phase, timerLabel: _timerLabel),
|
||||||
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|
||||||
|
// ── controls ─────────────────────────────────────────────────
|
||||||
if (_phase == _CallPhase.connected) ...[
|
if (_phase == _CallPhase.connected) ...[
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
@ -264,307 +197,138 @@ class _CallScreenState extends State<CallScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
],
|
],
|
||||||
|
|
||||||
if (_phase == _CallPhase.failed) ...[
|
if (_phase == _CallPhase.failed) ...[
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Panggilan belum tersambung. Pastikan device lawan login, paired, dan backend aktif.',
|
'Panggilan gagal.\nPastikan Agora App ID sudah diisi di app_constants.dart dan server backend aktif.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.white54, height: 1.5),
|
style: const TextStyle(color: Colors.white54, height: 1.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// ── end call button ───────────────────────────────────────────
|
||||||
_EndCallButton(onTap: _endCall),
|
_EndCallButton(onTap: _endCall),
|
||||||
|
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class IncomingCallScreen extends StatefulWidget {
|
// ─── IncomingCallScreen ───────────────────────────────────────────────────────
|
||||||
final String callerName;
|
|
||||||
final int? callerId;
|
|
||||||
final String? channelName;
|
|
||||||
final String? agoraToken;
|
|
||||||
final String? agoraAppId;
|
|
||||||
|
|
||||||
const IncomingCallScreen({
|
class IncomingCallScreen extends StatefulWidget {
|
||||||
super.key,
|
/// callerName bisa diisi dari FCM payload via extra go_router params.
|
||||||
this.callerName = 'Guardian',
|
/// Default 'Guardian' jika tidak ada.
|
||||||
this.callerId,
|
final String callerName;
|
||||||
this.channelName,
|
const IncomingCallScreen({super.key, this.callerName = 'Guardian'});
|
||||||
this.agoraToken,
|
|
||||||
this.agoraAppId,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<IncomingCallScreen> createState() => _IncomingCallScreenState();
|
State<IncomingCallScreen> createState() => _IncomingCallScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
||||||
int _secondsElapsed = 0;
|
static const _autoAnswerSeconds = 30;
|
||||||
Timer? _callTimer;
|
int _countdown = _autoAnswerSeconds;
|
||||||
Timer? _statePoll;
|
Timer? _autoTimer;
|
||||||
bool _responding = false;
|
bool _responding = false;
|
||||||
bool _connected = false;
|
|
||||||
bool _failed = false;
|
|
||||||
bool _muted = false;
|
|
||||||
bool _speakerOn = true;
|
|
||||||
String? _joinedChannel;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
sl<HapticService>().callIncoming();
|
sl<HapticService>().callIncoming();
|
||||||
sl<TtsService>().speak('Panggilan masuk dari ${widget.callerName}.');
|
sl<TtsService>().speak('Panggilan masuk dari ${widget.callerName}.');
|
||||||
|
|
||||||
|
// auto-answer countdown
|
||||||
|
_autoTimer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||||
|
if (!mounted) {
|
||||||
|
t.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _countdown--);
|
||||||
|
if (_countdown <= 0) {
|
||||||
|
t.cancel();
|
||||||
|
_accept();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_callTimer?.cancel();
|
_autoTimer?.cancel();
|
||||||
_statePoll?.cancel();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _accept() async {
|
Future<void> _accept() async {
|
||||||
if (_responding) return;
|
if (_responding) return;
|
||||||
setState(() => _responding = true);
|
setState(() => _responding = true);
|
||||||
|
_autoTimer?.cancel();
|
||||||
sl<TtsService>().speak('Menerima panggilan.');
|
sl<TtsService>().speak('Menerima panggilan.');
|
||||||
|
// Gabung ke channel yang sama (nama channel dari FCM payload — sementara hardcode)
|
||||||
final joined = await runFriendly<bool>(
|
await sl<CallService>().joinChannel(channelName: 'walkguide-call');
|
||||||
() => _joinIncomingChannel(),
|
if (mounted) context.go('/user/call');
|
||||||
onError: (_) {},
|
|
||||||
fallback: 'Panggilan gagal tersambung.',
|
|
||||||
) ??
|
|
||||||
false;
|
|
||||||
if (!mounted) return;
|
|
||||||
if (!joined || _joinedChannel == null || widget.callerId == null) {
|
|
||||||
setState(() {
|
|
||||||
_failed = true;
|
|
||||||
_responding = false;
|
|
||||||
});
|
|
||||||
sl<TtsService>().speak('Panggilan gagal tersambung.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await sl<CallService>().acceptIncomingCall(
|
|
||||||
callerId: widget.callerId!,
|
|
||||||
channelName: _joinedChannel!,
|
|
||||||
);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_connected = true;
|
|
||||||
_responding = false;
|
|
||||||
});
|
|
||||||
_callTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
if (mounted) setState(() => _secondsElapsed++);
|
|
||||||
});
|
|
||||||
_startIncomingStatePolling();
|
|
||||||
sl<TtsService>().speak('Panggilan tersambung.');
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startIncomingStatePolling() {
|
|
||||||
_statePoll?.cancel();
|
|
||||||
_statePoll = Timer.periodic(const Duration(seconds: 1), (_) async {
|
|
||||||
if (!mounted || _joinedChannel == null) return;
|
|
||||||
final state = await runFriendly<Map<String, dynamic>>(
|
|
||||||
() => sl<CallService>()
|
|
||||||
.getCallState(_joinedChannel)
|
|
||||||
.timeout(const Duration(seconds: 2)),
|
|
||||||
onError: (_) {},
|
|
||||||
fallback: 'Polling panggilan masuk gagal.',
|
|
||||||
);
|
|
||||||
if (state?['status']?.toString() == 'ENDED') {
|
|
||||||
await _finishIncomingRemoteEnded();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _finishIncomingRemoteEnded() async {
|
|
||||||
if (!mounted) return;
|
|
||||||
_callTimer?.cancel();
|
|
||||||
_statePoll?.cancel();
|
|
||||||
await sl<CallService>().leave();
|
|
||||||
sl<TtsService>().speak('Panggilan diakhiri oleh lawan bicara.');
|
|
||||||
if (mounted) context.go(await _homeRoute());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _decline() async {
|
Future<void> _decline() async {
|
||||||
if (_responding) return;
|
if (_responding) return;
|
||||||
setState(() => _responding = true);
|
setState(() => _responding = true);
|
||||||
|
_autoTimer?.cancel();
|
||||||
sl<TtsService>().speak('Panggilan ditolak.');
|
sl<TtsService>().speak('Panggilan ditolak.');
|
||||||
sl<CallService>().setRemoteUserOfflineCallback(null);
|
|
||||||
await sl<CallService>().endCall(
|
|
||||||
widget.callerId,
|
|
||||||
channelName: widget.channelName,
|
|
||||||
);
|
|
||||||
await sl<CallService>().clearPendingCall();
|
|
||||||
await sl<CallService>().leave();
|
await sl<CallService>().leave();
|
||||||
if (mounted) context.go(await _homeRoute());
|
if (mounted) context.go('/user/walkguide');
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _joinIncomingChannel() async {
|
|
||||||
sl<CallService>().setRemoteUserOfflineCallback(() {
|
|
||||||
unawaited(_finishIncomingRemoteEnded());
|
|
||||||
});
|
|
||||||
if (widget.callerId != null) {
|
|
||||||
final tokenData = await sl<CallService>().requestToken(
|
|
||||||
receiverId: widget.callerId!,
|
|
||||||
);
|
|
||||||
final channelName = tokenData?['channelName']?.toString();
|
|
||||||
final token = tokenData?['token']?.toString();
|
|
||||||
final appId = tokenData?['appId']?.toString();
|
|
||||||
final uid = (tokenData?['uid'] as num?)?.toInt() ?? 0;
|
|
||||||
if (channelName != null && channelName.isNotEmpty) {
|
|
||||||
_joinedChannel = channelName;
|
|
||||||
return sl<CallService>().joinChannel(
|
|
||||||
channelName: channelName,
|
|
||||||
token: token,
|
|
||||||
appId: appId,
|
|
||||||
uid: uid,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final fallbackChannel = widget.channelName;
|
|
||||||
if (fallbackChannel == null || fallbackChannel.isEmpty) return false;
|
|
||||||
_joinedChannel = fallbackChannel;
|
|
||||||
return sl<CallService>().joinChannel(
|
|
||||||
channelName: fallbackChannel,
|
|
||||||
token: widget.agoraToken,
|
|
||||||
appId: widget.agoraAppId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _endConnectedCall() async {
|
|
||||||
_callTimer?.cancel();
|
|
||||||
_statePoll?.cancel();
|
|
||||||
sl<CallService>().setRemoteUserOfflineCallback(null);
|
|
||||||
await sl<CallService>().endCall(
|
|
||||||
widget.callerId,
|
|
||||||
channelName: _joinedChannel,
|
|
||||||
);
|
|
||||||
await sl<CallService>().leave();
|
|
||||||
sl<TtsService>().speak('Panggilan diakhiri.');
|
|
||||||
if (mounted) context.go(await _homeRoute());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleMute() async {
|
|
||||||
setState(() => _muted = !_muted);
|
|
||||||
await sl<CallService>().setMuted(_muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleSpeaker() async {
|
|
||||||
setState(() => _speakerOn = !_speakerOn);
|
|
||||||
await sl<CallService>().setSpeakerEnabled(_speakerOn);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> _homeRoute() async {
|
|
||||||
final role = await sl<SecureStorage>().getUserRole();
|
|
||||||
return role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide';
|
|
||||||
}
|
|
||||||
|
|
||||||
String get _timerLabel {
|
|
||||||
final m = (_secondsElapsed ~/ 60).toString().padLeft(2, '0');
|
|
||||||
final s = (_secondsElapsed % 60).toString().padLeft(2, '0');
|
|
||||||
return '$m:$s';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_connected) {
|
return Scaffold(
|
||||||
return _CallScaffold(
|
backgroundColor: _kBg,
|
||||||
title: 'Terhubung',
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
const _Avatar(icon: Icons.call, color: _kGreen),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
Text(
|
|
||||||
widget.callerName,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_timerLabel,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: _kGreen,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
_ControlButton(
|
|
||||||
icon: _muted ? Icons.mic_off : Icons.mic,
|
|
||||||
label: _muted ? 'Unmute' : 'Mute',
|
|
||||||
onTap: _toggleMute,
|
|
||||||
active: _muted,
|
|
||||||
),
|
|
||||||
_ControlButton(
|
|
||||||
icon: _speakerOn ? Icons.volume_up : Icons.volume_off,
|
|
||||||
label: _speakerOn ? 'Speaker' : 'Earpiece',
|
|
||||||
onTap: _toggleSpeaker,
|
|
||||||
active: _speakerOn,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
_EndCallButton(onTap: _endConnectedCall),
|
|
||||||
const SizedBox(height: 56),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _CallScaffold(
|
// ── caller info ───────────────────────────────────────────────
|
||||||
title: 'Panggilan Masuk',
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Spacer(),
|
|
||||||
const Icon(Icons.call_received, color: _kGreen, size: 48),
|
const Icon(Icons.call_received, color: _kGreen, size: 48),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
const Text('Panggilan Masuk',
|
||||||
'Panggilan Masuk',
|
style: TextStyle(color: Colors.white54, fontSize: 14)),
|
||||||
style: TextStyle(color: Colors.white54, fontSize: 14),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(widget.callerName,
|
||||||
widget.callerName,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800)),
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// auto-answer countdown
|
||||||
Text(
|
Text(
|
||||||
_failed
|
'Auto-answer dalam $_countdown detik',
|
||||||
? 'Tidak bisa tersambung. Coba panggil ulang.'
|
style: const TextStyle(color: Colors.white38, fontSize: 13),
|
||||||
: 'Tekan Terima untuk menyambungkan panggilan.',
|
|
||||||
style: TextStyle(color: _failed ? _kRed : Colors.white38),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|
||||||
|
// ── accept / decline ──────────────────────────────────────────
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 48),
|
padding: const EdgeInsets.symmetric(horizontal: 48),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
|
// Decline
|
||||||
_RoundCallButton(
|
_RoundCallButton(
|
||||||
icon: Icons.call_end,
|
icon: Icons.call_end,
|
||||||
color: _kRed,
|
color: _kRed,
|
||||||
label: 'Tolak',
|
label: 'Tolak',
|
||||||
onTap: _responding ? null : _decline,
|
onTap: _responding ? null : _decline,
|
||||||
),
|
),
|
||||||
|
// Accept
|
||||||
_RoundCallButton(
|
_RoundCallButton(
|
||||||
icon: Icons.call,
|
icon: Icons.call,
|
||||||
color: _kGreen,
|
color: _kGreen,
|
||||||
@ -574,137 +338,55 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 56),
|
const SizedBox(height: 56),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CallScaffold extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final Widget child;
|
|
||||||
|
|
||||||
const _CallScaffold({required this.title, required this.child});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: _kBg,
|
|
||||||
body: DecoratedBox(
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [_kBg, Color(0xFF172554)],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: FadeSlideWrapper(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const SizedBox(width: 48),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white70,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 48),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(child: child),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sub-widgets ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
enum _CallPhase { calling, connected, failed }
|
enum _CallPhase { calling, connected, failed }
|
||||||
|
|
||||||
class _PhaseLabel extends StatelessWidget {
|
class _PhaseLabel extends StatelessWidget {
|
||||||
final _CallPhase phase;
|
final _CallPhase phase;
|
||||||
final String timerLabel;
|
final String timerLabel;
|
||||||
|
|
||||||
const _PhaseLabel({required this.phase, required this.timerLabel});
|
const _PhaseLabel({required this.phase, required this.timerLabel});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case _CallPhase.calling:
|
case _CallPhase.calling:
|
||||||
return const Text(
|
return const Text('Memanggil…',
|
||||||
'Memanggil...',
|
style: TextStyle(color: _kMuted, fontSize: 16));
|
||||||
style: TextStyle(color: _kMuted, fontSize: 16),
|
|
||||||
);
|
|
||||||
case _CallPhase.connected:
|
case _CallPhase.connected:
|
||||||
return Text(
|
return Text(timerLabel,
|
||||||
timerLabel,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: _kGreen,
|
color: _kGreen, fontSize: 22, fontWeight: FontWeight.w700));
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case _CallPhase.failed:
|
case _CallPhase.failed:
|
||||||
return const Text(
|
return const Text('Panggilan gagal',
|
||||||
'Panggilan gagal',
|
style: TextStyle(color: _kRed, fontSize: 16));
|
||||||
style: TextStyle(color: _kRed, fontSize: 16),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Avatar extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final Color color;
|
|
||||||
|
|
||||||
const _Avatar({required this.icon, required this.color});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
width: 124,
|
|
||||||
height: 124,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: color.withValues(alpha: 0.2),
|
|
||||||
border: Border.all(color: Colors.white, width: 3),
|
|
||||||
boxShadow: AppDecorations.avatarShadow,
|
|
||||||
),
|
|
||||||
child: Icon(icon, color: Colors.white, size: 56),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ControlButton extends StatelessWidget {
|
class _ControlButton extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final bool active;
|
final bool active;
|
||||||
|
const _ControlButton(
|
||||||
const _ControlButton({
|
{required this.icon,
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.active = false,
|
this.active = false});
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BounceTap(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@ -720,7 +402,8 @@ class _ControlButton extends StatelessWidget {
|
|||||||
child: Icon(icon, color: Colors.white, size: 28),
|
child: Icon(icon, color: Colors.white, size: 28),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(label, style: const TextStyle(color: Colors.white54)),
|
Text(label,
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 12)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -729,18 +412,17 @@ class _ControlButton extends StatelessWidget {
|
|||||||
|
|
||||||
class _EndCallButton extends StatelessWidget {
|
class _EndCallButton extends StatelessWidget {
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _EndCallButton({required this.onTap});
|
const _EndCallButton({required this.onTap});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BounceTap(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 74,
|
width: 72,
|
||||||
height: 74,
|
height: 72,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: _kRed,
|
color: _kRed,
|
||||||
@ -748,7 +430,8 @@ class _EndCallButton extends StatelessWidget {
|
|||||||
child: const Icon(Icons.call_end, color: Colors.white, size: 32),
|
child: const Icon(Icons.call_end, color: Colors.white, size: 32),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
const Text('Akhiri', style: TextStyle(color: Colors.white54)),
|
const Text('Akhiri',
|
||||||
|
style: TextStyle(color: Colors.white54, fontSize: 12)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -760,38 +443,32 @@ class _RoundCallButton extends StatelessWidget {
|
|||||||
final Color color;
|
final Color color;
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
const _RoundCallButton(
|
||||||
const _RoundCallButton({
|
{required this.icon,
|
||||||
required this.icon,
|
|
||||||
required this.color,
|
required this.color,
|
||||||
required this.label,
|
required this.label,
|
||||||
this.onTap,
|
this.onTap});
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BounceTap(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: onTap == null ? 0.4 : 1,
|
opacity: onTap == null ? 0.4 : 1.0,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 74,
|
width: 72,
|
||||||
height: 74,
|
height: 72,
|
||||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||||
child: Icon(icon, color: Colors.white, size: 32),
|
child: Icon(icon, color: Colors.white, size: 32),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(label, style: const TextStyle(color: Colors.white70)),
|
Text(label,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int? _asInt(dynamic value) {
|
|
||||||
if (value is num) return value.toInt();
|
|
||||||
return int.tryParse(value?.toString() ?? '');
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
Call data layer.
|
|
||||||
|
|
||||||
This layer is reserved for call remote data sources and repository implementations over `/shared/call/**`.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Call domain layer.
|
|
||||||
|
|
||||||
This layer is reserved for call session entities, repository contracts, and call use cases.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Guardian dashboard application layer.
|
|
||||||
|
|
||||||
This layer is reserved for dashboard, map, SOS, notification, AI config, shortcut, and geofence Cubits.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Guardian dashboard data layer.
|
|
||||||
|
|
||||||
This layer is reserved for `/guardian/**` data sources and repository implementations.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Guardian dashboard domain layer.
|
|
||||||
|
|
||||||
This layer is reserved for Guardian dashboard entities, repository contracts, and use cases.
|
|
||||||
@ -9,10 +9,6 @@ import 'package:intl/intl.dart';
|
|||||||
import '../../../app/injection_container.dart';
|
import '../../../app/injection_container.dart';
|
||||||
import '../../../core/errors/friendly_error.dart';
|
import '../../../core/errors/friendly_error.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
|
||||||
import '../../../core/theme/app_decorations.dart';
|
|
||||||
import '../../../core/theme/app_text_styles.dart';
|
|
||||||
import '../../../shared/widgets/animations/animations.dart';
|
|
||||||
|
|
||||||
Dio get _api => sl<ApiClient>().dio;
|
Dio get _api => sl<ApiClient>().dio;
|
||||||
|
|
||||||
@ -146,22 +142,13 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
return SafeArea(
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [AppColors.softBlueBg, Colors.white],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: FadeSlideWrapper(
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// ── Header ──────────────────────────────────────────────────────
|
// ── Header ──────────────────────────────────────────────────────
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -170,7 +157,11 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'User Logs',
|
'User Logs',
|
||||||
style: AppTextStyles.heading,
|
style: GoogleFonts.outfit(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: const Color(0xFF0F172A),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
_needsPairing
|
_needsPairing
|
||||||
@ -194,7 +185,7 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// ── Filter chips ─────────────────────────────────────────────────
|
// ── Filter chips ─────────────────────────────────────────────────
|
||||||
if (!_needsPairing && !_loading && _error == null)
|
if (!_needsPairing && !_loading && _error == null)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 36,
|
height: 36,
|
||||||
@ -234,7 +225,7 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
|||||||
if (!_needsPairing && !_loading && _error == null)
|
if (!_needsPairing && !_loading && _error == null)
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ── Body ─────────────────────────────────────────────────────────
|
// ── Body ─────────────────────────────────────────────────────────
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _loading
|
child: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
@ -247,23 +238,16 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
|||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: _load,
|
onRefresh: _load,
|
||||||
color: const Color(0xFF1A56DB),
|
color: const Color(0xFF1A56DB),
|
||||||
child: ListView(
|
child: ListView.builder(
|
||||||
children: [
|
itemCount: _filtered.length,
|
||||||
StaggerWrapper(
|
itemBuilder: (ctx, i) =>
|
||||||
children: [
|
_LogCard(item: _filtered[i]),
|
||||||
for (final item in _filtered)
|
|
||||||
_LogCard(item: item),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -274,9 +258,8 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFFBEB),
|
color: const Color(0xFFFFFBEB),
|
||||||
borderRadius: AppDecorations.cardRadius,
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: const Color(0xFFFDE68A)),
|
border: Border.all(color: const Color(0xFFFDE68A)),
|
||||||
boxShadow: AppDecorations.cardShadow,
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@ -355,9 +338,9 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// DATA MODEL
|
// DATA MODEL
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _LogItem {
|
class _LogItem {
|
||||||
final int id;
|
final int id;
|
||||||
@ -382,9 +365,9 @@ class _LogItem {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// LOG CARD
|
// LOG CARD
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _LogCard extends StatelessWidget {
|
class _LogCard extends StatelessWidget {
|
||||||
final _LogItem item;
|
final _LogItem item;
|
||||||
@ -394,15 +377,7 @@ class _LogCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final meta = _logMeta(item.logType);
|
final meta = _logMeta(item.logType);
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
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(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -414,10 +389,15 @@ class _LogCard extends StatelessWidget {
|
|||||||
height: 38,
|
height: 38,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: meta.color.withValues(alpha: 0.12),
|
color: meta.color.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(50),
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(meta.icon, color: meta.color, size: 18),
|
child: Icon(meta.icon, color: meta.color, size: 18),
|
||||||
),
|
),
|
||||||
|
Container(
|
||||||
|
width: 1.5,
|
||||||
|
height: 22,
|
||||||
|
color: const Color(0xFFE2E8F0),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@ -449,8 +429,7 @@ class _LogCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (item.description != null &&
|
if (item.description != null && item.description!.isNotEmpty)
|
||||||
item.description!.isNotEmpty)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 3),
|
padding: const EdgeInsets.only(top: 3),
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -468,7 +447,6 @@ class _LogCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -481,9 +459,9 @@ class _LogCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// LOG METADATA
|
// LOG METADATA
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _LogMeta {
|
class _LogMeta {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|||||||
@ -9,11 +9,7 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import '../../../app/injection_container.dart';
|
import '../../../app/injection_container.dart';
|
||||||
import '../../../core/errors/friendly_error.dart';
|
import '../../../core/errors/friendly_error.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
|
||||||
import '../../../core/theme/app_decorations.dart';
|
|
||||||
import '../../../core/theme/app_text_styles.dart';
|
|
||||||
import '../../../core/utils/operation_guard.dart';
|
import '../../../core/utils/operation_guard.dart';
|
||||||
import '../../../shared/widgets/animations/animations.dart';
|
|
||||||
|
|
||||||
Dio get _api => sl<ApiClient>().dio;
|
Dio get _api => sl<ApiClient>().dio;
|
||||||
|
|
||||||
@ -81,8 +77,7 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
},
|
},
|
||||||
onError: (error) => setState(() {
|
onError: (error) => setState(() {
|
||||||
_error = error is DioException
|
_error = error is DioException
|
||||||
? friendlyDioMessage(error,
|
? friendlyDioMessage(error, fallback: 'Gagal memuat konfigurasi AI.')
|
||||||
fallback: 'Gagal memuat konfigurasi AI.')
|
|
||||||
: 'Gagal memuat konfigurasi AI. Coba refresh lagi.';
|
: 'Gagal memuat konfigurasi AI. Coba refresh lagi.';
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -141,22 +136,13 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
return SafeArea(
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [AppColors.softBlueBg, Colors.white],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: FadeSlideWrapper(
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// ── Header ──────────────────────────────────────────────────────
|
// ── Header ──────────────────────────────────────────────────────
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -165,7 +151,11 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'AI Config',
|
'AI Config',
|
||||||
style: AppTextStyles.heading,
|
style: GoogleFonts.outfit(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: const Color(0xFF0F172A),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Konfigurasi deteksi YOLO untuk User',
|
'Konfigurasi deteksi YOLO untuk User',
|
||||||
@ -193,7 +183,7 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// ── Body ─────────────────────────────────────────────────────────
|
// ── Body ─────────────────────────────────────────────────────────
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _loading
|
child: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
@ -206,20 +196,15 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildConfigForm() {
|
Widget _buildConfigForm() {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
child: StaggerWrapper(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Column(
|
// ── Confidence Threshold ──────────────────────────────────────────
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// ── Confidence Threshold ──────────────────────────────────────────
|
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
title: 'Confidence Threshold',
|
title: 'Confidence Threshold',
|
||||||
subtitle:
|
subtitle:
|
||||||
@ -238,8 +223,7 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12, vertical: 4),
|
horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: const Color(0xFF1A56DB).withValues(alpha: 0.1),
|
||||||
const Color(0xFF1A56DB).withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -259,8 +243,7 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
max: 0.9,
|
max: 0.9,
|
||||||
divisions: 8,
|
divisions: 8,
|
||||||
activeColor: const Color(0xFF1A56DB),
|
activeColor: const Color(0xFF1A56DB),
|
||||||
onChanged: (v) =>
|
onChanged: (v) => setState(() => _confidenceThreshold = v),
|
||||||
setState(() => _confidenceThreshold = v),
|
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@ -278,7 +261,7 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// ── Alert Distances ───────────────────────────────────────────────
|
// ── Alert Distances ───────────────────────────────────────────────
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
title: 'Jarak Peringatan',
|
title: 'Jarak Peringatan',
|
||||||
subtitle: 'Batas jarak (meter) untuk level peringatan',
|
subtitle: 'Batas jarak (meter) untuk level peringatan',
|
||||||
@ -302,15 +285,13 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text('Jarak Dekat',
|
Text('Jarak Dekat',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13, color: const Color(0xFF0F172A))),
|
||||||
color: const Color(0xFF0F172A))),
|
|
||||||
]),
|
]),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12, vertical: 4),
|
horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: const Color(0xFFDC2626).withValues(alpha: 0.1),
|
||||||
const Color(0xFFDC2626).withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -349,15 +330,13 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text('Jarak Sedang',
|
Text('Jarak Sedang',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13, color: const Color(0xFF0F172A))),
|
||||||
color: const Color(0xFF0F172A))),
|
|
||||||
]),
|
]),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12, vertical: 4),
|
horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: const Color(0xFFD97706).withValues(alpha: 0.1),
|
||||||
const Color(0xFFD97706).withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -377,15 +356,14 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
max: 8.0,
|
max: 8.0,
|
||||||
divisions: 7,
|
divisions: 7,
|
||||||
activeColor: const Color(0xFFD97706),
|
activeColor: const Color(0xFFD97706),
|
||||||
onChanged: (v) =>
|
onChanged: (v) => setState(() => _alertDistanceMedium = v),
|
||||||
setState(() => _alertDistanceMedium = v),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// ── Max Inference FPS ─────────────────────────────────────────────
|
// ── Max Inference FPS ─────────────────────────────────────────────
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
title: 'Max Inference FPS',
|
title: 'Max Inference FPS',
|
||||||
subtitle:
|
subtitle:
|
||||||
@ -404,8 +382,7 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12, vertical: 4),
|
horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: const Color(0xFF059669).withValues(alpha: 0.1),
|
||||||
const Color(0xFF059669).withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -444,7 +421,7 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// ── Enabled Labels ────────────────────────────────────────────────
|
// ── Enabled Labels ────────────────────────────────────────────────
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
title: 'Label yang Diaktifkan',
|
title: 'Label yang Diaktifkan',
|
||||||
subtitle: 'Jenis objek yang akan dideteksi AI',
|
subtitle: 'Jenis objek yang akan dideteksi AI',
|
||||||
@ -458,11 +435,10 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => setState(() => _enabledLabels = label),
|
onTap: () => setState(() => _enabledLabels = label),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding:
|
||||||
horizontal: 16, vertical: 8),
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: selected ? const Color(0xFF7C3AED) : Colors.white,
|
||||||
selected ? const Color(0xFF7C3AED) : Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: selected
|
color: selected
|
||||||
@ -475,9 +451,8 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: selected
|
color:
|
||||||
? Colors.white
|
selected ? Colors.white : const Color(0xFF64748B),
|
||||||
: const Color(0xFF64748B),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -487,7 +462,7 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// ── Save button ───────────────────────────────────────────────────
|
// ── Save button ───────────────────────────────────────────────────
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
@ -511,8 +486,6 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -523,9 +496,8 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFFBEB),
|
color: const Color(0xFFFFFBEB),
|
||||||
borderRadius: AppDecorations.cardRadius,
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: const Color(0xFFFDE68A)),
|
border: Border.all(color: const Color(0xFFFDE68A)),
|
||||||
boxShadow: AppDecorations.cardShadow,
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@ -580,9 +552,9 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// SECTION CARD
|
// SECTION CARD
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _SectionCard extends StatelessWidget {
|
class _SectionCard extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@ -604,10 +576,16 @@ class _SectionCard extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.cardWhite,
|
color: Colors.white,
|
||||||
borderRadius: AppDecorations.cardRadius,
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
boxShadow: AppDecorations.cardShadow,
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.03),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -618,7 +596,7 @@ class _SectionCard extends StatelessWidget {
|
|||||||
height: 34,
|
height: 34,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: iconColor.withValues(alpha: 0.1),
|
color: iconColor.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: iconColor, size: 18),
|
child: Icon(icon, color: iconColor, size: 18),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -8,9 +8,6 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/errors/friendly_error.dart';
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
|
||||||
import '../../core/theme/app_decorations.dart';
|
|
||||||
import '../../shared/widgets/animations/animations.dart';
|
|
||||||
import '../../shared/widgets/feature_page.dart';
|
import '../../shared/widgets/feature_page.dart';
|
||||||
|
|
||||||
class GuardianMapScreen extends StatefulWidget {
|
class GuardianMapScreen extends StatefulWidget {
|
||||||
@ -109,9 +106,8 @@ class _GuardianMapCard extends StatelessWidget {
|
|||||||
final center = _pointFrom(location) ??
|
final center = _pointFrom(location) ??
|
||||||
(points.isNotEmpty ? points.first : null) ??
|
(points.isNotEmpty ? points.first : null) ??
|
||||||
const LatLng(-7.2575, 112.7521);
|
const LatLng(-7.2575, 112.7521);
|
||||||
return Container(
|
return ClipRRect(
|
||||||
decoration: AppDecorations.card,
|
borderRadius: BorderRadius.circular(20),
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: FlutterMap(
|
child: FlutterMap(
|
||||||
options: MapOptions(initialCenter: center, initialZoom: 16),
|
options: MapOptions(initialCenter: center, initialZoom: 16),
|
||||||
children: [
|
children: [
|
||||||
@ -125,7 +121,7 @@ class _GuardianMapCard extends StatelessWidget {
|
|||||||
Polyline(
|
Polyline(
|
||||||
points: points,
|
points: points,
|
||||||
strokeWidth: 4,
|
strokeWidth: 4,
|
||||||
color: AppColors.primaryBlue,
|
color: const Color(0xFF2563EB),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -175,18 +171,10 @@ class _TimelineList extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView(
|
return ListView.separated(
|
||||||
children: [
|
itemCount: segments.length,
|
||||||
StaggerWrapper(
|
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||||
children: [
|
itemBuilder: (_, index) => _TimelineCard(segment: segments[index]),
|
||||||
for (final segment in segments)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
|
||||||
child: _TimelineCard(segment: segment),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -201,10 +189,9 @@ class _TimelineCard extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.cardWhite,
|
color: Colors.white,
|
||||||
borderRadius: AppDecorations.cardRadius,
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
boxShadow: AppDecorations.cardShadow,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@ -212,10 +199,10 @@ class _TimelineCard extends StatelessWidget {
|
|||||||
width: 42,
|
width: 42,
|
||||||
height: 42,
|
height: 42,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.softBlueBg,
|
color: const Color(0xFFEFF6FF),
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: Icon(segment.icon, color: AppColors.primaryBlue),
|
child: Icon(segment.icon, color: const Color(0xFF1D4ED8)),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@ -8,9 +8,6 @@ import 'package:record/record.dart';
|
|||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/errors/friendly_error.dart';
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
|
||||||
import '../../core/theme/app_decorations.dart';
|
|
||||||
import '../../shared/widgets/animations/animations.dart';
|
|
||||||
import '../../shared/widgets/feature_page.dart';
|
import '../../shared/widgets/feature_page.dart';
|
||||||
|
|
||||||
class GuardianSendNotifScreen extends StatefulWidget {
|
class GuardianSendNotifScreen extends StatefulWidget {
|
||||||
@ -135,14 +132,19 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
|||||||
subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
|
subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
FadeSlideWrapper(
|
Container(
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.cardWhite,
|
color: Colors.white,
|
||||||
borderRadius: AppDecorations.cardRadius,
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
boxShadow: AppDecorations.cardShadow,
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF1E293B).withValues(alpha: 0.06),
|
||||||
|
blurRadius: 22,
|
||||||
|
offset: const Offset(0, 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@ -183,8 +185,8 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
|||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: const Color(0xFFF8FAFC),
|
||||||
borderRadius: AppDecorations.cardRadius,
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@ -227,9 +229,7 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
|||||||
),
|
),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _loading ? null : _toggleRecording,
|
onPressed: _loading ? null : _toggleRecording,
|
||||||
icon: Icon(_recording
|
icon: Icon(_recording ? Icons.stop : Icons.fiber_manual_record),
|
||||||
? Icons.stop
|
|
||||||
: Icons.fiber_manual_record),
|
|
||||||
label: Text(_recording ? 'Stop' : 'Record'),
|
label: Text(_recording ? 'Stop' : 'Record'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: _recording
|
backgroundColor: _recording
|
||||||
@ -260,7 +260,6 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,9 +9,6 @@ import '../../app/injection_container.dart';
|
|||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/errors/friendly_error.dart';
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
|
||||||
import '../../core/theme/app_decorations.dart';
|
|
||||||
import '../../shared/widgets/animations/animations.dart';
|
|
||||||
import '../../core/storage/secure_storage.dart';
|
import '../../core/storage/secure_storage.dart';
|
||||||
import '../../shared/widgets/feature_page.dart';
|
import '../../shared/widgets/feature_page.dart';
|
||||||
|
|
||||||
@ -51,8 +48,6 @@ class GuardianSettingsScreen extends StatelessWidget {
|
|||||||
title: 'Guardian Settings',
|
title: 'Guardian Settings',
|
||||||
subtitle: 'Account, pairing, AI tools, and server',
|
subtitle: 'Account, pairing, AI tools, and server',
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
|
||||||
StaggerWrapper(
|
|
||||||
children: [
|
children: [
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: Icons.link,
|
icon: Icons.link,
|
||||||
@ -72,8 +67,6 @@ class GuardianSettingsScreen extends StatelessWidget {
|
|||||||
subtitle: 'Atur threshold deteksi dan label yang aktif.',
|
subtitle: 'Atur threshold deteksi dan label yang aktif.',
|
||||||
onTap: () => context.go('/guardian/ai-config'),
|
onTap: () => context.go('/guardian/ai-config'),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => _changeServer(context),
|
onPressed: () => _changeServer(context),
|
||||||
@ -110,28 +103,19 @@ class _SettingsTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BounceTap(
|
return Container(
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.cardWhite,
|
color: Colors.white,
|
||||||
borderRadius: AppDecorations.cardRadius,
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
boxShadow: AppDecorations.cardShadow,
|
|
||||||
),
|
),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Container(
|
leading: Icon(icon, color: const Color(0xFF1D4ED8)),
|
||||||
width: 44,
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||||
height: 44,
|
|
||||||
decoration: AppDecorations.iconCircle(),
|
|
||||||
child: Icon(icon, color: AppColors.primaryBlue),
|
|
||||||
),
|
|
||||||
title:
|
|
||||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
|
|
||||||
subtitle: Text(subtitle),
|
subtitle: Text(subtitle),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
),
|
onTap: onTap,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -158,10 +158,8 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => StatefulBuilder(
|
builder: (dialogContext) => StatefulBuilder(
|
||||||
builder: (context, setDialogState) => AlertDialog(
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
title: Text(
|
title: Text(_labelFromKey(item['commandKey']?.toString() ?? '') ??
|
||||||
_labelFromKey(item['commandKey']?.toString() ?? '') ??
|
'Voice Command'),
|
||||||
'Voice Command',
|
|
||||||
),
|
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@ -183,12 +181,10 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext, false),
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel')),
|
||||||
),
|
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
child: const Text('Save'),
|
child: const Text('Save')),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -213,9 +209,8 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => StatefulBuilder(
|
builder: (dialogContext) => StatefulBuilder(
|
||||||
builder: (context, setDialogState) => AlertDialog(
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
title: Text(
|
title: Text(_labelFromKey(item['shortcutKey']?.toString() ?? '') ??
|
||||||
_labelFromKey(item['shortcutKey']?.toString() ?? '') ?? 'Shortcut',
|
'Shortcut'),
|
||||||
),
|
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@ -235,31 +230,6 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
|||||||
prefixIcon: Icon(Icons.numbers_outlined),
|
prefixIcon: Icon(Icons.numbers_outlined),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
ActionChip(
|
|
||||||
avatar: const Icon(Icons.volume_up_outlined, size: 18),
|
|
||||||
label: const Text('Volume Up'),
|
|
||||||
onPressed: () {
|
|
||||||
buttonName.text = 'Volume Up';
|
|
||||||
buttonCode.text = '24';
|
|
||||||
setDialogState(() {});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ActionChip(
|
|
||||||
avatar: const Icon(Icons.volume_down_outlined, size: 18),
|
|
||||||
label: const Text('Volume Down'),
|
|
||||||
onPressed: () {
|
|
||||||
buttonName.text = 'Volume Down';
|
|
||||||
buttonCode.text = '25';
|
|
||||||
setDialogState(() {});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
value: enabled,
|
value: enabled,
|
||||||
@ -271,12 +241,10 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext, false),
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel')),
|
||||||
),
|
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
child: const Text('Save'),
|
child: const Text('Save')),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -305,9 +273,8 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
|||||||
},
|
},
|
||||||
onError: (message) {
|
onError: (message) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context)
|
||||||
context,
|
.showSnackBar(SnackBar(content: Text(message)));
|
||||||
).showSnackBar(SnackBar(content: Text(message)));
|
|
||||||
},
|
},
|
||||||
fallback: 'Konfigurasi belum bisa disimpan.',
|
fallback: 'Konfigurasi belum bisa disimpan.',
|
||||||
);
|
);
|
||||||
@ -334,15 +301,18 @@ class _EndpointCard extends StatelessWidget {
|
|||||||
'',
|
'',
|
||||||
) ??
|
) ??
|
||||||
'Item #${item['id'] ?? '-'}';
|
'Item #${item['id'] ?? '-'}';
|
||||||
final subtitle = _firstText(item, [
|
final subtitle = _firstText(
|
||||||
|
item,
|
||||||
|
[
|
||||||
'triggerPhrase',
|
'triggerPhrase',
|
||||||
'buttonName',
|
'buttonName',
|
||||||
'description',
|
'description',
|
||||||
'action',
|
'action',
|
||||||
'shortcut',
|
'shortcut',
|
||||||
'status',
|
'status',
|
||||||
'createdAt',
|
'createdAt'
|
||||||
]) ??
|
],
|
||||||
|
) ??
|
||||||
'Data aktif';
|
'Data aktif';
|
||||||
final enabled = item['enabled'] != false;
|
final enabled = item['enabled'] != false;
|
||||||
return Container(
|
return Container(
|
||||||
@ -368,15 +338,11 @@ class _EndpointCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(title,
|
||||||
title,
|
style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(subtitle,
|
||||||
subtitle,
|
style: const TextStyle(color: Color(0xFF64748B))),
|
||||||
style: const TextStyle(color: Color(0xFF64748B)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
@ -449,6 +415,7 @@ String? _labelFromKey(String value) {
|
|||||||
return value
|
return value
|
||||||
.split('_')
|
.split('_')
|
||||||
.where((part) => part.isNotEmpty)
|
.where((part) => part.isNotEmpty)
|
||||||
.map((part) => part[0].toUpperCase() + part.substring(1).toLowerCase())
|
.map((part) =>
|
||||||
|
part[0].toUpperCase() + part.substring(1).toLowerCase())
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
Home application layer.
|
|
||||||
|
|
||||||
This layer is reserved for role-specific home state orchestration.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Home data layer.
|
|
||||||
|
|
||||||
This layer is reserved for home/dashboard data adapters.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Home domain layer.
|
|
||||||
|
|
||||||
This layer is reserved for home/dashboard domain entities and use cases.
|
|
||||||
@ -1,8 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/theme/app_colors.dart';
|
|
||||||
import '../../core/theme/app_text_styles.dart';
|
|
||||||
|
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
final String role;
|
final String role;
|
||||||
|
|
||||||
@ -10,24 +7,12 @@ class HomeScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
return Scaffold(
|
||||||
decoration: const BoxDecoration(
|
appBar: AppBar(title: const Text('Dashboard Walk Guide')),
|
||||||
gradient: LinearGradient(
|
body: Center(
|
||||||
colors: [AppColors.softBlueBg, Colors.white],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
role == 'ROLE_ADMIN'
|
role == 'ROLE_ADMIN' ? 'Selamat Datang Admin!' : 'Mode Walk Guide Siap!',
|
||||||
? 'Selamat Datang Admin!'
|
style: const TextStyle(fontSize: 24),
|
||||||
: 'Mode Walk Guide Siap!',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: AppTextStyles.heading,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
import '../../../core/secure_storage.dart';
|
import '../../../core/secure_storage.dart';
|
||||||
import '../../auth/presentation/login_screen.dart';
|
import '../../auth/presentation/login_screen.dart';
|
||||||
|
import '../../../../main.dart';
|
||||||
|
|
||||||
class UserDashboardScreen extends StatefulWidget {
|
class UserDashboardScreen extends StatefulWidget {
|
||||||
const UserDashboardScreen({super.key});
|
const UserDashboardScreen({super.key});
|
||||||
@ -11,8 +12,7 @@ class UserDashboardScreen extends StatefulWidget {
|
|||||||
State<UserDashboardScreen> createState() => _UserDashboardScreenState();
|
State<UserDashboardScreen> createState() => _UserDashboardScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UserDashboardScreenState extends State<UserDashboardScreen>
|
class _UserDashboardScreenState extends State<UserDashboardScreen> with TickerProviderStateMixin {
|
||||||
with TickerProviderStateMixin {
|
|
||||||
CameraController? _camCtrl;
|
CameraController? _camCtrl;
|
||||||
late AnimationController _radarCtrl;
|
late AnimationController _radarCtrl;
|
||||||
late Animation<double> _radarAnim;
|
late Animation<double> _radarAnim;
|
||||||
@ -31,10 +31,8 @@ class _UserDashboardScreenState extends State<UserDashboardScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initCamera() async {
|
Future<void> _initCamera() async {
|
||||||
final cameras = await availableCameras();
|
|
||||||
if (cameras.isEmpty) return;
|
if (cameras.isEmpty) return;
|
||||||
_camCtrl = CameraController(cameras[0], ResolutionPreset.medium,
|
_camCtrl = CameraController(cameras[0], ResolutionPreset.high, enableAudio: false);
|
||||||
enableAudio: false);
|
|
||||||
await _camCtrl!.initialize();
|
await _camCtrl!.initialize();
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
@ -87,8 +85,7 @@ class _UserDashboardScreenState extends State<UserDashboardScreen>
|
|||||||
gradient: RadialGradient(
|
gradient: RadialGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Colors.transparent,
|
Colors.transparent,
|
||||||
const Color(0xFF10B981)
|
const Color(0xFF10B981).withValues(alpha: 0.05 + _radarCtrl.value * 0.08),
|
||||||
.withValues(alpha: 0.05 + _radarCtrl.value * 0.08),
|
|
||||||
],
|
],
|
||||||
stops: const [0.5, 1.0],
|
stops: const [0.5, 1.0],
|
||||||
radius: 1.4,
|
radius: 1.4,
|
||||||
@ -130,8 +127,7 @@ class _UserDashboardScreenState extends State<UserDashboardScreen>
|
|||||||
),
|
),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Container(
|
Container(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.54),
|
color: Colors.black.withValues(alpha: 0.54),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@ -162,8 +158,7 @@ class _UserDashboardScreenState extends State<UserDashboardScreen>
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _logout,
|
onPressed: _logout,
|
||||||
icon: const Icon(Icons.power_settings_new,
|
icon: const Icon(Icons.power_settings_new, color: Colors.white, size: 26),
|
||||||
color: Colors.white, size: 26),
|
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: Colors.redAccent.withValues(alpha: 0.8),
|
backgroundColor: Colors.redAccent.withValues(alpha: 0.8),
|
||||||
),
|
),
|
||||||
@ -209,19 +204,15 @@ class _UserDashboardScreenState extends State<UserDashboardScreen>
|
|||||||
color: const Color(0x33F59E0B),
|
color: const Color(0x33F59E0B),
|
||||||
borderRadius: BorderRadius.circular(7),
|
borderRadius: BorderRadius.circular(7),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.warning_amber_rounded,
|
child: const Icon(Icons.warning_amber_rounded, color: Color(0xFFF59E0B), size: 16),
|
||||||
color: Color(0xFFF59E0B), size: 16),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Text('Obstacle ahead',
|
Text('Obstacle ahead',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
color: Colors.white,
|
color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500)),
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w500)),
|
|
||||||
Text('2.1m — Haptic alert sent',
|
Text('2.1m — Haptic alert sent',
|
||||||
style:
|
style: GoogleFonts.inter(color: Colors.white60, fontSize: 11)),
|
||||||
GoogleFonts.inter(color: Colors.white60, fontSize: 11)),
|
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
@ -243,12 +234,9 @@ class _UserDashboardScreenState extends State<UserDashboardScreen>
|
|||||||
),
|
),
|
||||||
child: Column(children: [
|
child: Column(children: [
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Expanded(
|
Expanded(child: _bigBtn(const Color(0xCC1A56DB), Icons.mic, () {})),
|
||||||
child: _bigBtn(const Color(0xCC1A56DB), Icons.mic, () {})),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(child: _bigBtn(const Color(0xCCDC2626), Icons.phone_in_talk, () {})),
|
||||||
child: _bigBtn(
|
|
||||||
const Color(0xCCDC2626), Icons.phone_in_talk, () {})),
|
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
@ -302,8 +290,7 @@ class _RadarPainter extends CustomPainter {
|
|||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 1.2;
|
..strokeWidth = 1.2;
|
||||||
for (final r in [48.0, 34.0, 20.0]) {
|
for (final r in [48.0, 34.0, 20.0]) {
|
||||||
paint.color =
|
paint.color = const Color(0xFF60EFAB).withValues(alpha: 0.15 + (50 - r) / 100);
|
||||||
const Color(0xFF60EFAB).withValues(alpha: 0.15 + (50 - r) / 100);
|
|
||||||
canvas.drawCircle(center, r, paint);
|
canvas.drawCircle(center, r, paint);
|
||||||
}
|
}
|
||||||
paint
|
paint
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
Manual application layer.
|
|
||||||
|
|
||||||
This layer is reserved for manual/TTS instruction state orchestration.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Manual data layer.
|
|
||||||
|
|
||||||
This layer is reserved for local command and shortcut documentation data sources.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Manual domain layer.
|
|
||||||
|
|
||||||
This layer is reserved for manual section entities and instruction use cases.
|
|
||||||
@ -1,10 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../core/services/voice_command_handler.dart';
|
import '../../core/services/voice_command_handler.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
|
||||||
import '../../core/theme/app_decorations.dart';
|
|
||||||
import '../../shared/widgets/animations/animations.dart';
|
|
||||||
import '../../shared/widgets/feature_page.dart';
|
|
||||||
|
|
||||||
class ManualScreen extends StatelessWidget {
|
class ManualScreen extends StatelessWidget {
|
||||||
const ManualScreen({super.key});
|
const ManualScreen({super.key});
|
||||||
@ -12,38 +8,16 @@ class ManualScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final commands = VoiceCommandKey.values.map((key) => key.name).toList();
|
final commands = VoiceCommandKey.values.map((key) => key.name).toList();
|
||||||
return FeaturePage(
|
return Scaffold(
|
||||||
title: 'Manual',
|
appBar: AppBar(title: const Text('Manual')),
|
||||||
subtitle: 'Voice command yang tersedia',
|
body: ListView.separated(
|
||||||
child: ListView(
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
itemCount: commands.length,
|
||||||
StaggerWrapper(
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
children: [
|
itemBuilder: (context, index) => ListTile(
|
||||||
for (final command in commands)
|
leading: const Icon(Icons.record_voice_over),
|
||||||
Container(
|
title: Text(commands[index]),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
Manual presentation layer.
|
|
||||||
|
|
||||||
This layer is reserved for manual pages and widgets.
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
Navigation mode application layer.
|
|
||||||
|
|
||||||
This layer is reserved for navigation Cubit/BLoC orchestration.
|
|
||||||
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