Compare commits

..

No commits in common. "d2b3534ddeae5f79ae53b26810874f32ea76fde7" and "a629357e8c4b2c536791f60464b6236eec9eedb9" have entirely different histories.

202 changed files with 33654 additions and 12257 deletions

5
.gitignore vendored
View File

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

@ -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 [![Flutter](https://img.shields.io/badge/Flutter-Clean_Architecture-02569B?style=flat-square&logo=flutter&logoColor=white)](https://flutter.dev/)
[![Spring Boot](https://img.shields.io/badge/Spring_Boot-REST_API-6DB33F?style=flat-square&logo=spring&logoColor=white)](https://spring.io/projects/spring-boot)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-Database-4169E1?style=flat-square&logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![Firebase](https://img.shields.io/badge/Firebase-FCM-FFCA28?style=flat-square&logo=firebase&logoColor=black)](https://firebase.google.com/)
[![Agora](https://img.shields.io/badge/Agora-VoIP-099DFD?style=flat-square)](https://www.agora.io/)
[![License](https://img.shields.io/badge/License-MIT-22c55e?style=flat-square)](LICENSE)
This repository is no longer a backend-only skeleton. The current codebase contains: ![Last Updated](https://img.shields.io/badge/Last_Updated-WIP-blue?style=for-the-badge)
![Author](https://img.shields.io/badge/By-evan--william-brightgreen?style=for-the-badge)
- 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 V1V16 on first start
# Swagger UI: http://202.46.28.160:8080/swagger-ui.html
# Health check: GET http://202.46.28.160:8080/api/v1/auth/ping
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/` | | 23 | OOAD diagrams, OpenAPI YAML drafted | 🔄 In Progress |
| 4 | Spring Boot auth, pairing, entities, migrations | Implemented | | 4 | Spring Boot: Auth, Pairing, Entity, Migration V4V16 | 🔄 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*

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on --> <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
} }
await ignoreInitFailure(() => sl<TtsService>().init(), label: 'TTS init');
await sl<YoloDetector>().init();
if (!kIsWeb) {
await ignoreInitFailure(() => sl<SttService>().init(), label: 'STT init');
}
sl<VoiceCommandHandler>().loadDefaultCommands(); sl<VoiceCommandHandler>().loadDefaultCommands();
if (!kIsWeb) {
await sl<FcmService>().init();
}
} }

View File

@ -25,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()),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,9 +13,9 @@ import '../storage/secure_storage.dart';
/// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user. /// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user.
/// ///
/// Subscriptions yang dipakai: /// Subscriptions yang dipakai:
/// Guardian â /topic/location/{userId} live GPS update /// Guardian /topic/location/{userId} live GPS update
/// Guardian â /queue/sos/{guardianId} SOS alert real-time /// Guardian /queue/sos/{guardianId} SOS alert real-time
/// User â /queue/notif/{userId} notifikasi dari Guardian /// User /queue/notif/{userId} notifikasi dari Guardian
class WebSocketService { class WebSocketService {
final SecureStorage _storage; final SecureStorage _storage;
@ -26,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.');
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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> {
], ],
), ),
), ),
),
], ],
), ),
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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