Merge remote-tracking branch 'origin/Update-UI-+-Agora-Call-Guardian-User'
This commit is contained in:
commit
66da2473e1
4
.gitignore
vendored
4
.gitignore
vendored
@ -40,8 +40,12 @@ 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/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
|
||||||
|
|||||||
411
Exam Guide.md
Normal file
411
Exam Guide.md
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
# 📱 Final Exam: Integrated Mobile Application Project
|
||||||
|
### Flutter × Spring Boot × Object-Oriented Analysis and Design
|
||||||
|
#### Group Assignment (3 Members) — Industry-Grade Level
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This final exam requires your group to design, develop, and deliver a **fully integrated mobile application system** consisting of:
|
||||||
|
|
||||||
|
- A **Flutter mobile frontend** that consumes a RESTful API
|
||||||
|
- A **Spring Boot backend** that exposes the API with proper layering, security, and persistence
|
||||||
|
- A rigorous **OOAD process** — designed before coding, then verified against the final implementation
|
||||||
|
|
||||||
|
The project is evaluated across three distinct dimensions with different grade weights. This is intentional: **design thinking (OOAD) is the foundation**, engineering quality (Flutter + Spring Boot) is the execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Group Formation & Role Distribution
|
||||||
|
|
||||||
|
Each group consists of **exactly 3 members**. Each member owns one primary pillar but all members must understand and contribute to all three.
|
||||||
|
|
||||||
|
| Role | Primary Pillar | Core Responsibilities |
|
||||||
|
|---|---|---|
|
||||||
|
| **OOAD Lead** | Object-Oriented Analysis & Design | Leads all design artifacts (use case, class, sequence, state diagrams), enforces design pattern compliance across both codebases, owns the design traceability matrix |
|
||||||
|
| **Flutter Engineer** | Mobile Frontend | Clean Architecture implementation, state management, UI/UX, performance benchmarking |
|
||||||
|
| **Backend Engineer** | Spring Boot API | REST API design, service/repository layers, database schema, security (JWT), API documentation, backend testing |
|
||||||
|
|
||||||
|
> **Important:** Role ownership means accountability, not isolation. All members must commit code to both repositories. The OOAD Lead reviews both codebases for pattern compliance — this is a graded responsibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Topic
|
||||||
|
|
||||||
|
Your group is free to choose any application domain, provided it:
|
||||||
|
|
||||||
|
- Models a real-world problem with identifiable actors, use cases, and entities
|
||||||
|
- Requires meaningful backend logic (not just CRUD — include at least one business rule or workflow)
|
||||||
|
- Has a clear primary user and at least one secondary actor (admin, system, or external service)
|
||||||
|
|
||||||
|
**Example domains** *(create your own — do not copy)*:
|
||||||
|
- Hospital appointment and queue management
|
||||||
|
- Campus asset borrowing and return tracking
|
||||||
|
- Community marketplace with seller verification flow
|
||||||
|
- Event ticketing with seat allocation logic
|
||||||
|
- Employee attendance with approval workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pillar 1 — Object-Oriented Analysis & Design (OOAD)
|
||||||
|
|
||||||
|
OOAD is evaluated in **two phases**: design artifacts produced before coding, and a traceability audit conducted against the final code.
|
||||||
|
|
||||||
|
### Phase 1A: Pre-Development Design Artifacts
|
||||||
|
|
||||||
|
All diagrams must be produced using **PlantUML, draw.io, or StarUML** and submitted as part of the Week 2–3 checkpoint. Diagrams drawn by hand are not accepted.
|
||||||
|
|
||||||
|
| Artifact | Diagram Type | Requirement |
|
||||||
|
|---|---|---|
|
||||||
|
| Requirements model | Use Case Diagram | All actors, use cases, include/extend relationships |
|
||||||
|
| Structural model | Class Diagram | All domain classes with attributes, methods, visibility, and relationships (association, aggregation, composition, inheritance) |
|
||||||
|
| Behavioral model | Sequence Diagrams | At least 3 key interactions (e.g., login, create resource, approval flow) showing object collaboration |
|
||||||
|
| State model | State Machine Diagram | At least 1 entity with meaningful state transitions (e.g., Order: PENDING → CONFIRMED → COMPLETED → CANCELLED) |
|
||||||
|
| Data model | ERD (Crow's Foot notation) | All entities, PKs/FKs, cardinality — must align with the class diagram |
|
||||||
|
| Architecture model | Component Diagram | Flutter app, Spring Boot layers, database, and external services as components with interfaces |
|
||||||
|
|
||||||
|
### Phase 1B: Design Pattern Compliance
|
||||||
|
|
||||||
|
Your system must implement **at least 4 GoF design patterns** across the full stack, with at least 1 from each category:
|
||||||
|
|
||||||
|
| Category | Required Count | Examples |
|
||||||
|
|---|---|---|
|
||||||
|
| Creational | ≥ 1 | Factory Method, Builder, Singleton |
|
||||||
|
| Structural | ≥ 1 | Adapter, Facade, Decorator, Proxy |
|
||||||
|
| Behavioral | ≥ 2 | Strategy, Observer, Command, Template Method, Chain of Responsibility |
|
||||||
|
|
||||||
|
Each pattern must be documented with:
|
||||||
|
1. Pattern name and category
|
||||||
|
2. Which class/component implements it (with file path)
|
||||||
|
3. UML class diagram showing the pattern in context
|
||||||
|
4. Justification — why this pattern was chosen over alternatives
|
||||||
|
|
||||||
|
### Phase 1C: Design Traceability Audit (Post-Development)
|
||||||
|
|
||||||
|
After development is complete, the OOAD Lead conducts a **traceability audit** comparing the pre-development design to the final code:
|
||||||
|
|
||||||
|
- For each class in the original class diagram: does it exist in code? If not, explain why.
|
||||||
|
- For each design pattern: show the exact code that implements it (file + line reference).
|
||||||
|
- For each sequence diagram: trace the method call chain in the actual code.
|
||||||
|
- Document all **design deviations** — cases where implementation diverged from design — with a written rationale for each deviation.
|
||||||
|
|
||||||
|
> A perfect match between design and code is not required. Thoughtful, documented deviations are acceptable. Undocumented deviations are penalized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pillar 2 — Flutter Mobile Frontend
|
||||||
|
|
||||||
|
### Technical Requirements
|
||||||
|
|
||||||
|
| Category | Requirement |
|
||||||
|
|---|---|
|
||||||
|
| **Flutter Version** | Flutter 3.x (Stable channel) |
|
||||||
|
| **Architecture** | Clean Architecture — strict 4-layer separation: `domain / data / application / presentation` |
|
||||||
|
| **State Management** | BLoC or Riverpod (consistent throughout; mixing is not allowed) |
|
||||||
|
| **Navigation** | Go Router with at least 6 distinct screens and route guards for authenticated routes |
|
||||||
|
| **API Communication** | `Dio` with interceptors for JWT token injection, refresh token handling, and error normalization |
|
||||||
|
| **Local Persistence** | Hive or SQLite for offline caching of at least one core data entity |
|
||||||
|
| **Authentication** | JWT-based login/register consuming the Spring Boot auth endpoint |
|
||||||
|
| **UI/UX** | Custom widget library (min. 5 reusable widgets), responsive layout, consistent design system |
|
||||||
|
| **Error Handling** | Typed failure classes using `Either` (dartz) or equivalent; no raw `try/catch` in UI layer |
|
||||||
|
|
||||||
|
### Folder Structure (Enforced)
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── core/ # shared utilities, theme, constants, error types
|
||||||
|
├── features/
|
||||||
|
│ └── [feature]/
|
||||||
|
│ ├── domain/ # entities, repository interfaces, use cases
|
||||||
|
│ ├── data/ # repository implementations, DTOs, data sources
|
||||||
|
│ └── presentation/ # BLoC/Cubit, pages, widgets
|
||||||
|
└── main.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
Any structure deviating from feature-first modular layout must be approved in writing before Week 4.
|
||||||
|
|
||||||
|
### Advanced Features (Choose at least 2)
|
||||||
|
|
||||||
|
- Real-time updates via WebSocket or Server-Sent Events from Spring Boot
|
||||||
|
- Push notifications triggered by backend events (FCM)
|
||||||
|
- Offline-first with background sync to Spring Boot API
|
||||||
|
- Animated transitions using custom `PageRouteBuilder` or Lottie
|
||||||
|
- Internationalization (i18n) with at least 2 languages
|
||||||
|
- Biometric authentication (fingerprint/face ID) as second factor
|
||||||
|
|
||||||
|
### Flutter Testing & Benchmarking
|
||||||
|
|
||||||
|
#### Functional Testing
|
||||||
|
|
||||||
|
| Type | Tool | Minimum |
|
||||||
|
|---|---|---|
|
||||||
|
| Unit Testing | `flutter_test` | All use cases and repository implementations |
|
||||||
|
| Widget Testing | `flutter_test` | At least 5 core UI components |
|
||||||
|
| Integration Testing | `integration_test` | At least 3 end-to-end flows against the live Spring Boot API |
|
||||||
|
|
||||||
|
#### Performance Benchmarking
|
||||||
|
|
||||||
|
Run all benchmarks on a **physical Android device in profile mode** (`flutter run --profile`). Emulator results alone are not accepted.
|
||||||
|
|
||||||
|
| Metric | Tool | Pass Threshold |
|
||||||
|
|---|---|---|
|
||||||
|
| Memory — baseline | DevTools → Memory tab | Report heap at launch (MB) |
|
||||||
|
| Memory — leak check | DevTools → Memory tab | No steady growth over 10 repeated navigations |
|
||||||
|
| Frame rate / jank | DevTools → Performance tab | ≥ 90% frames < 16ms (60fps target) |
|
||||||
|
| CPU profile | DevTools → CPU Profiler | Flame graph for top 3 CPU-heavy operations |
|
||||||
|
| API latency (client-side) | Dio interceptor logs | All core endpoints < 1500ms |
|
||||||
|
| Cold start time | `--trace-startup --profile` | `timeToFirstFrame` < 3000ms |
|
||||||
|
| APK size | `flutter build apk --analyze-size` | Release APK < 50MB |
|
||||||
|
|
||||||
|
Each benchmark must be reported with: objective, tool, method, results table, threshold comparison, and DevTools screenshot.
|
||||||
|
|
||||||
|
**Regression requirement:** Run benchmarks at Week 5 (mid-sprint) and at Week 7 (final). Submit a delta table comparing both runs. Any metric that degrades > 20% must include a root cause analysis and remediation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pillar 3 — Spring Boot Backend
|
||||||
|
|
||||||
|
### Technical Requirements
|
||||||
|
|
||||||
|
| Category | Requirement |
|
||||||
|
|---|---|
|
||||||
|
| **Java Version** | Java 17+ |
|
||||||
|
| **Framework** | Spring Boot 3.x |
|
||||||
|
| **Architecture** | Layered: `Controller → Service → Repository` (no logic in Controller, no DB calls in Service) |
|
||||||
|
| **Database** | PostgreSQL or MySQL with JPA/Hibernate; schema migrations via Flyway |
|
||||||
|
| **Security** | Spring Security with JWT (access token + refresh token); role-based access control (RBAC) |
|
||||||
|
| **API Design** | RESTful conventions; versioned endpoints (`/api/v1/...`); proper HTTP status codes |
|
||||||
|
| **Validation** | Bean Validation (`@Valid`) on all request DTOs; global exception handler via `@ControllerAdvice` |
|
||||||
|
| **Documentation** | Swagger/OpenAPI 3.0 via `springdoc-openapi`; all endpoints documented with schemas |
|
||||||
|
| **Configuration** | Environment-separated configs (`application-dev.yml`, `application-prod.yml`); no hardcoded secrets |
|
||||||
|
|
||||||
|
### API Contract Requirements
|
||||||
|
|
||||||
|
- Minimum **10 distinct REST endpoints** covering the full application domain
|
||||||
|
- All endpoints must return a **consistent response envelope**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {},
|
||||||
|
"message": "Operation successful",
|
||||||
|
"timestamp": "2025-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Error responses must include: `success: false`, `errorCode`, `message`, and `timestamp`
|
||||||
|
- API contract must be defined as an **OpenAPI 3.0 YAML file** committed to the repository before development begins (design-first)
|
||||||
|
|
||||||
|
### Backend Testing & Benchmarking
|
||||||
|
|
||||||
|
#### Functional Testing
|
||||||
|
|
||||||
|
| Type | Tool | Minimum |
|
||||||
|
|---|---|---|
|
||||||
|
| Unit Testing | JUnit 5 + Mockito | All service classes; mock repository layer |
|
||||||
|
| Integration Testing | `@SpringBootTest` + MockMvc | All controller endpoints; test DB via Testcontainers |
|
||||||
|
| Code Coverage | JaCoCo | ≥ 70% line coverage on `service` and `controller` packages |
|
||||||
|
|
||||||
|
#### Load Benchmarking
|
||||||
|
|
||||||
|
| Metric | Tool | Pass Threshold |
|
||||||
|
|---|---|---|
|
||||||
|
| API throughput | Apache JMeter or k6 | ≥ 100 req/s under 50 concurrent users |
|
||||||
|
| p95 latency | JMeter or k6 | < 500ms under load |
|
||||||
|
| Error rate under load | JMeter or k6 | < 1% at 50 concurrent users |
|
||||||
|
| DB query performance | Spring Actuator + slow query log | No query > 200ms for standard operations |
|
||||||
|
| JVM memory under load | Actuator `/actuator/metrics` | No heap exhaustion during 5-min load test |
|
||||||
|
|
||||||
|
Load test scenario: simulate 50 concurrent users performing a realistic user journey (login → fetch list → create resource → logout) for 5 minutes. Export JMeter `.jtl` report or k6 summary as evidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
### 1. 📁 GitHub Repositories (2 repos)
|
||||||
|
|
||||||
|
**Flutter Repository** (`[GroupName]-[AppName]-mobile-final`):
|
||||||
|
- Feature-first clean architecture folder structure
|
||||||
|
- GitHub Actions workflow (`.github/workflows/flutter.yml`) — green at submission
|
||||||
|
- `README.md`: setup instructions, environment variables, APK download link
|
||||||
|
- All 3 members must have commits; branching strategy enforced
|
||||||
|
|
||||||
|
**Spring Boot Repository** (`[GroupName]-[AppName]-backend`):
|
||||||
|
- Layered package structure (`controller`, `service`, `repository`, `domain`, `dto`, `config`)
|
||||||
|
- Flyway migration scripts in `resources/db/migration/`
|
||||||
|
- OpenAPI YAML committed before Week 4
|
||||||
|
- `README.md`: setup instructions, environment variables, how to run locally
|
||||||
|
- JaCoCo HTML coverage report committed or published via CI
|
||||||
|
|
||||||
|
### 2. 📦 APK File
|
||||||
|
|
||||||
|
- Release build named `[GroupName]_[AppName]_FinalExam.apk`
|
||||||
|
- Must connect to a **live, publicly deployed** Spring Boot backend (not localhost)
|
||||||
|
- Acceptable deployment platforms: Railway, Render, Fly.io, or any public URL
|
||||||
|
|
||||||
|
### 3. 📄 Written Report
|
||||||
|
|
||||||
|
Format: PDF, minimum **25 pages** (excluding cover and references). Language: English or Bahasa Indonesia.
|
||||||
|
|
||||||
|
| # | Section | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Cover Page | System name, group name, member names & student IDs, course name, date |
|
||||||
|
| 2 | Abstract | 200–250 words covering the system, tech stack, and key findings |
|
||||||
|
| 3 | Introduction | Problem background, objectives, target users, scope and limitations |
|
||||||
|
| 4 | OOAD — Pre-Development | All design artifacts (use case, class, sequence, state, ERD, component diagrams) |
|
||||||
|
| 5 | OOAD — Design Patterns | Documentation of all 4+ patterns with UML and code references |
|
||||||
|
| 6 | OOAD — Traceability Audit | Design-to-code mapping table; documented deviations with rationale |
|
||||||
|
| 7 | System Architecture | Flutter Clean Architecture, Spring Boot layers, API communication flow diagram |
|
||||||
|
| 8 | API Contract | OpenAPI summary, endpoint table, request/response examples |
|
||||||
|
| 9 | Flutter Implementation | Key features, state management flow, custom widgets, advanced features |
|
||||||
|
| 10 | Spring Boot Implementation | Service layer logic, security config, DB schema, Flyway migrations |
|
||||||
|
| 11 | Flutter Testing & Benchmarking | Test results, all 7 benchmark metrics with evidence and delta table |
|
||||||
|
| 12 | Backend Testing & Benchmarking | JUnit/integration test results, JMeter/k6 load test report |
|
||||||
|
| 13 | Team Contribution | Per-member task table with percentage, cross-verified with Git commit history |
|
||||||
|
| 14 | Conclusion | Achievements, design lessons learned, challenges, future improvements |
|
||||||
|
| 15 | References | IEEE format |
|
||||||
|
|
||||||
|
### 4. Presentation
|
||||||
|
|
||||||
|
- Duration: **15–20 minutes**
|
||||||
|
- Structure:
|
||||||
|
- Team introduction + system overview (2 min)
|
||||||
|
- OOAD design walkthrough — diagrams and pattern explanation (4–5 min)
|
||||||
|
- Flutter app live demo — all major flows (5–6 min)
|
||||||
|
- Spring Boot API demo — Swagger UI + one live API call (3 min)
|
||||||
|
- Benchmark results summary (2 min)
|
||||||
|
- All 3 members must present a section
|
||||||
|
- Upload to YouTube (unlisted) or Google Drive
|
||||||
|
|
||||||
|
> **Live Session:** Each member will be questioned individually on the section they presented and on OOAD concepts. Individual scores may differ from the group score.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timeline
|
||||||
|
|
||||||
|
| Phase | Activity | Deadline |
|
||||||
|
|---|---|---|
|
||||||
|
| Week 1 | Group registration + topic proposal + actor/use case list | Day 7 |
|
||||||
|
| Week 2–3 | All OOAD Phase 1A artifacts submitted + OpenAPI YAML drafted | Day 21 |
|
||||||
|
| Week 4 | Architecture approved; development sprint begins | Day 28 |
|
||||||
|
| Week 5 | Mid-sprint benchmark run (Flutter + Backend) submitted | Day 35 |
|
||||||
|
| Week 6–7 | Feature freeze; Flutter ↔ Spring Boot integration testing | Day 49 |
|
||||||
|
| Week 7 | Final benchmark run; delta table completed | Day 49 |
|
||||||
|
| Week 8 | OOAD traceability audit completed; report writing + video | Day 56 |
|
||||||
|
| **Final** | **All deliverables submitted** | **Day 60, 23:59** |
|
||||||
|
| Final+1 | Live presentation & individual Q&A | Scheduled by lecturer |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grading Rubric
|
||||||
|
|
||||||
|
Each pillar is graded **independently out of 100 points**. Students receive three separate scores — one per pillar. There is no combined final grade: each score stands on its own and is recorded separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🥇 Pillar 1 — OOAD Score (/ 100)
|
||||||
|
|
||||||
|
| Component | Points | Criteria |
|
||||||
|
|---|---|---|
|
||||||
|
| Pre-development design artifacts | 35 | Completeness of all 6 required diagrams, correctness of notation, diagram tool used (no hand-drawn), submitted before coding begins |
|
||||||
|
| Design pattern implementation | 25 | Correct application of ≥ 4 GoF patterns (min 1 per category), UML documented per pattern, each traceable to code with file path |
|
||||||
|
| Traceability audit | 25 | Coverage of class-to-code mapping, quality and honesty of deviation documentation, sequence diagram trace accuracy |
|
||||||
|
| Cross-pillar design consistency | 15 | Alignment between class diagram, ERD, Flutter domain layer entities, and Spring Boot domain/entity classes |
|
||||||
|
|
||||||
|
**OOAD Penalty:**
|
||||||
|
|
||||||
|
| Violation | Deduction |
|
||||||
|
|---|---|
|
||||||
|
| OOAD artifacts submitted after Week 3 (after coding begins) | −20 points |
|
||||||
|
| Diagram produced with unpermitted tool (e.g., hand-drawn, screenshot of AI output) | −15 points |
|
||||||
|
| Design pattern claimed but not traceable in code | −8 points per pattern |
|
||||||
|
| Traceability audit missing for > 30% of class diagram classes | −10 points |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🥈 Pillar 2 — Flutter Mobile Score (/ 100)
|
||||||
|
|
||||||
|
| Component | Points | Criteria |
|
||||||
|
|---|---|---|
|
||||||
|
| Clean Architecture compliance | 25 | Strict 4-layer separation enforced, no cross-layer violations, feature-first folder structure correct, dependency direction correct |
|
||||||
|
| Features & UX quality | 20 | All required screens functional, JWT auth flow works against live API, custom widget library present, error states handled |
|
||||||
|
| Testing — unit & widget | 15 | All use cases and repositories covered, at least 5 widget tests, test quality (meaningful assertions, not just coverage padding) |
|
||||||
|
| Testing — integration | 10 | At least 3 end-to-end flows tested against live Spring Boot API, not mocked |
|
||||||
|
| Performance benchmarking | 20 | All 7 metrics reported on physical device in profile mode, DevTools screenshots provided, delta table (mid vs final), root cause for any regression > 20% |
|
||||||
|
| Report clarity | 10 | Report is complete and have clear explanation |
|
||||||
|
|
||||||
|
**Flutter Penalty:**
|
||||||
|
|
||||||
|
| Violation | Deduction |
|
||||||
|
|---|---|
|
||||||
|
| Responsive Design fail | −10 points |
|
||||||
|
| Benchmarks run on emulator only (no physical device) | −10 points |
|
||||||
|
| Missing delta table (mid-sprint vs final benchmark) | −8 points |
|
||||||
|
| State management inconsistency (mixing BLoC and Riverpod) | −10 points |
|
||||||
|
| Raw `try/catch` found in presentation layer | −5 points per occurrence (max −15) |
|
||||||
|
| APK connects to localhost instead of deployed backend | −15 points |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🥉 Pillar 3 — Spring Boot Score (/ 100)
|
||||||
|
|
||||||
|
| Component | Points | Criteria |
|
||||||
|
|---|---|---|
|
||||||
|
| API design & OpenAPI contract | 25 | ≥ 10 endpoints, consistent response envelope, versioned routes, OpenAPI 3.0 YAML committed before Week 4, Swagger UI accessible |
|
||||||
|
| Layered architecture & security | 25 | Strict Controller → Service → Repository separation, JWT with access + refresh token, RBAC with at least 2 roles, no hardcoded secrets |
|
||||||
|
| Testing — unit & integration | 25 | JUnit 5 + Mockito for all service classes, MockMvc + Testcontainers for all controllers, JaCoCo ≥ 70% on `service` and `controller` packages |
|
||||||
|
| Load benchmarking | 25 | All 5 metrics reported (throughput, p95 latency, error rate, DB query time, JVM heap), JMeter `.jtl` or k6 summary exported, analysis against pass thresholds |
|
||||||
|
|
||||||
|
**Spring Boot Penalty:**
|
||||||
|
|
||||||
|
| Violation | Deduction |
|
||||||
|
|---|---|
|
||||||
|
| Hardcoded secrets (API keys, DB passwords) in any file | −15 points |
|
||||||
|
| JaCoCo coverage below 70% | −10 points |
|
||||||
|
| No Flyway migrations (schema managed manually) | −8 points |
|
||||||
|
| OpenAPI YAML committed after Week 4 | −10 points |
|
||||||
|
| Load test run with < 50 concurrent users | −10 points |
|
||||||
|
| Business logic found directly in Controller class | −8 points per occurrence (max −16) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Universal Penalty (Applied to All Three Pillar Scores)
|
||||||
|
|
||||||
|
| Violation | Deduction |
|
||||||
|
|---|---|
|
||||||
|
| Late submission (per day, applied to all pillars) | −5 points per pillar |
|
||||||
|
| Missing deliverable | −15 points from the relevant pillar |
|
||||||
|
| Plagiarized code (any source) | 0 on all three pillars |
|
||||||
|
| Member with < 10% commits and no other contribution evidence | That member's individual pillar scores reduced by 20 points each |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Academic Integrity
|
||||||
|
|
||||||
|
- All code must be original. Open-source libraries are permitted with proper attribution in both READMEs and the report.
|
||||||
|
- Use of AI coding assistants is **permitted but must be disclosed** in a dedicated "AI Tool Usage" section in the report, listing which tools were used, for which tasks, and how outputs were reviewed and understood.
|
||||||
|
- Design artifacts must be produced by the group. AI-generated diagrams submitted without annotation will be identified during the live Q&A.
|
||||||
|
- Plagiarism between groups or from public repositories results in **zero marks for all involved groups**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Submission Checklist
|
||||||
|
|
||||||
|
- [ ] Flutter GitHub repository (Actions pipeline green, branch protection active, 3+ merged PRs)
|
||||||
|
- [ ] Spring Boot GitHub repository (JaCoCo report committed, OpenAPI YAML present, Flyway migrations included)
|
||||||
|
- [ ] APK file connecting to live deployed backend (`[GroupName]_FinalExam.apk`)
|
||||||
|
- [ ] Written report PDF (≥ 25 pages, all 16 sections complete, benchmark delta table included)
|
||||||
|
- [ ] Demo video link (YouTube unlisted or Google Drive, all 3 members presenting)
|
||||||
|
- [ ] OOAD traceability matrix (Section 6 of report)
|
||||||
|
- [ ] Mid-sprint benchmark results (Sections 11 & 12 of report)
|
||||||
|
- [ ] JMeter/k6 load test export (appendix or Drive link)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contact & Questions
|
||||||
|
|
||||||
|
All questions must be submitted through the official course channel. Questions submitted at least **48 hours before any deadline** are guaranteed a response. Design artifact reviews (Week 2–3) require a scheduled appointment — contact the lecturer by Week 1 to book a slot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Build systems you can defend, designs you can explain, and code that reflects your thinking. 🚀*
|
||||||
7
walkguide-backend/demo/backend-run.err.log
Normal file
7
walkguide-backend/demo/backend-run.err.log
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Set-Location : A positional parameter cannot be found that accepts argument 'PROGRAM'.
|
||||||
|
At line:1 char:1
|
||||||
|
+ Set-Location C:\COBA PROGRAM SEMESTER 4\UAS FLUTTER FT. SPRINGBOOT da ...
|
||||||
|
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
+ CategoryInfo : InvalidArgument: (:) [Set-Location], ParameterBindingException
|
||||||
|
+ FullyQualifiedErrorId : PositionalParameterNotFound,Microsoft.PowerShell.Commands.SetLocationCommand
|
||||||
|
|
||||||
2867
walkguide-backend/demo/backend-run.log
Normal file
2867
walkguide-backend/demo/backend-run.log
Normal file
File diff suppressed because it is too large
Load Diff
@ -104,6 +104,13 @@
|
|||||||
<!-- yang di-copy dari Agora open-source token builder. Tidak perlu library eksternal. -->
|
<!-- 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>
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
package com.walkguide.config;
|
||||||
|
|
||||||
|
import com.google.auth.oauth2.GoogleCredentials;
|
||||||
|
import com.google.firebase.FirebaseApp;
|
||||||
|
import com.google.firebase.FirebaseOptions;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.ResourceLoader;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class FirebaseConfig {
|
||||||
|
|
||||||
|
private final ResourceLoader resourceLoader;
|
||||||
|
|
||||||
|
@Value("${firebase.credentials-path:classpath:firebase/google-services-admin.json}")
|
||||||
|
private String credentialsPath;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void initializeFirebase() {
|
||||||
|
if (!FirebaseApp.getApps().isEmpty()) {
|
||||||
|
log.info("[FIREBASE] FirebaseApp already initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Resource resource = resourceLoader.getResource(credentialsPath);
|
||||||
|
if (!resource.exists() || !resource.isReadable()) {
|
||||||
|
log.warn("[FIREBASE] Credential not found/readable at {}. FCM runs in log-only fallback.", credentialsPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (InputStream in = resource.getInputStream()) {
|
||||||
|
FirebaseOptions options = FirebaseOptions.builder()
|
||||||
|
.setCredentials(GoogleCredentials.fromStream(in))
|
||||||
|
.build();
|
||||||
|
FirebaseApp.initializeApp(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("[FIREBASE] Firebase Admin initialized from {}", credentialsPath);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[FIREBASE] Failed to initialize Firebase Admin. FCM fallback active: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,9 +14,12 @@ import jakarta.validation.Valid;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -36,35 +39,76 @@ public class CallController {
|
|||||||
@Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
|
@Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
|
||||||
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
|
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
|
||||||
@Valid @RequestBody CallTokenRequest req) {
|
@Valid @RequestBody CallTokenRequest req) {
|
||||||
|
|
||||||
Long callerId = SecurityHelper.getCurrentUserId();
|
Long callerId = SecurityHelper.getCurrentUserId();
|
||||||
AgoraTokenResponse response = agoraTokenService.generateToken(callerId, req.getReceiverId());
|
AgoraTokenResponse response = agoraTokenService.generateToken(callerId, req.getReceiverId());
|
||||||
|
|
||||||
log.info("[CALL] Token generated | caller={} receiver={} channel={}",
|
log.info("[CALL] Token generated | caller={} receiver={} channel={}",
|
||||||
callerId, req.getReceiverId(), response.getChannelName());
|
callerId, req.getReceiverId(), response.getChannelName());
|
||||||
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
|
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/notify")
|
@PostMapping("/notify")
|
||||||
@Operation(summary = "Notify receiver of incoming call")
|
@Operation(summary = "Notify receiver of incoming call")
|
||||||
public ResponseEntity<ApiResponse<Void>> notifyCall(
|
public ResponseEntity<ApiResponse<Void>> notifyCall(@Valid @RequestBody CallNotifyRequest req) {
|
||||||
@Valid @RequestBody CallNotifyRequest req) {
|
|
||||||
|
|
||||||
Long callerId = SecurityHelper.getCurrentUserId();
|
Long callerId = SecurityHelper.getCurrentUserId();
|
||||||
String message = callNotificationService.notifyIncomingCall(callerId, req);
|
String message = callNotificationService.notifyIncomingCall(callerId, req);
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null, message));
|
return ResponseEntity.ok(ApiResponse.ok(null, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/accept")
|
||||||
|
@Operation(summary = "Receiver accepts incoming call")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, String>>> acceptCall(@RequestBody Map<String, String> body) {
|
||||||
|
Long receiverId = SecurityHelper.getCurrentUserId();
|
||||||
|
Long callerId = Long.parseLong(body.get("callerId"));
|
||||||
|
String channelName = body.get("channelName");
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(
|
||||||
|
callNotificationService.acceptCall(receiverId, callerId, channelName),
|
||||||
|
"Call accepted"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pending")
|
||||||
|
@Operation(summary = "Get pending incoming call for logged-in receiver")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, String>>> pendingCall() {
|
||||||
|
Long receiverId = SecurityHelper.getCurrentUserId();
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getPendingCall(receiverId), "Pending call"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/pending")
|
||||||
|
@Operation(summary = "Clear pending incoming call for logged-in receiver")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> clearPendingCall() {
|
||||||
|
Long receiverId = SecurityHelper.getCurrentUserId();
|
||||||
|
callNotificationService.clearPendingCall(receiverId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(null, "Pending call cleared"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/accepted")
|
||||||
|
@Operation(summary = "Get accepted call for logged-in caller")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, String>>> acceptedCall() {
|
||||||
|
Long callerId = SecurityHelper.getCurrentUserId();
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getAcceptedCall(callerId), "Accepted call"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/accepted")
|
||||||
|
@Operation(summary = "Clear accepted call for logged-in caller")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> clearAcceptedCall() {
|
||||||
|
Long callerId = SecurityHelper.getCurrentUserId();
|
||||||
|
callNotificationService.clearAcceptedCall(callerId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(null, "Accepted call cleared"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/state")
|
||||||
|
@Operation(summary = "Get call state by Agora channel")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, String>>> callState(@RequestParam String channelName) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getCallState(channelName), "Call state"));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/end")
|
@PostMapping("/end")
|
||||||
@Operation(summary = "Notify end of call")
|
@Operation(summary = "Notify end of call")
|
||||||
public ResponseEntity<ApiResponse<Void>> endCall(
|
public ResponseEntity<ApiResponse<Void>> endCall(@RequestBody Map<String, String> body) {
|
||||||
@RequestBody Map<String, Long> body) {
|
|
||||||
|
|
||||||
Long callerId = SecurityHelper.getCurrentUserId();
|
Long callerId = SecurityHelper.getCurrentUserId();
|
||||||
Long otherId = body.get("otherId");
|
Long otherId = Long.parseLong(body.get("otherId"));
|
||||||
callNotificationService.notifyCallEnded(callerId, otherId);
|
String channelName = body.get("channelName");
|
||||||
|
callNotificationService.notifyCallEnded(callerId, otherId, channelName);
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
|
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package com.walkguide.exception;
|
package com.walkguide.exception;
|
||||||
|
|
||||||
import com.walkguide.dto.ApiResponse;
|
import com.walkguide.dto.ApiResponse;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
@ -29,6 +30,13 @@ 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) {
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|||||||
@ -4,11 +4,14 @@ import com.walkguide.dto.request.CallNotifyRequest;
|
|||||||
import com.walkguide.entity.User;
|
import com.walkguide.entity.User;
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
import com.walkguide.repository.UserRepository;
|
import com.walkguide.repository.UserRepository;
|
||||||
|
import com.walkguide.websocket.LocationBroadcaster;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -17,29 +20,38 @@ 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("receiverUid", String.valueOf(req.getReceiverUid()));
|
||||||
|
|
||||||
|
pendingCalls.put(receiver.getId(), payload);
|
||||||
|
acceptedCalls.remove(callerId);
|
||||||
|
callStates.put(req.getChannelName(), payload);
|
||||||
|
locationBroadcaster.broadcastCall(receiver.getId(), payload);
|
||||||
|
|
||||||
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
|
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
|
||||||
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId());
|
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId());
|
||||||
return "Panggilan dikirim (receiver mungkin tidak menerima push notification)";
|
return "Panggilan dikirim via realtime fallback.";
|
||||||
}
|
}
|
||||||
|
|
||||||
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
|
|
||||||
Map<String, String> payload = Map.of(
|
|
||||||
"type", "INCOMING_CALL",
|
|
||||||
"callerId", String.valueOf(callerId),
|
|
||||||
"callerName", callerName,
|
|
||||||
"channelName", req.getChannelName(),
|
|
||||||
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
|
|
||||||
"receiverUid", String.valueOf(req.getReceiverUid())
|
|
||||||
);
|
|
||||||
|
|
||||||
fcmService.sendHighPriority(
|
fcmService.sendHighPriority(
|
||||||
receiver.getFcmToken(),
|
receiver.getFcmToken(),
|
||||||
"Panggilan Masuk",
|
"Panggilan Masuk",
|
||||||
@ -52,22 +64,111 @@ public class CallNotificationService {
|
|||||||
return "Notifikasi panggilan berhasil dikirim";
|
return "Notifikasi panggilan berhasil dikirim";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<String, String> acceptCall(Long receiverId, Long callerId, String channelName) {
|
||||||
|
User receiver = userRepository.findById(receiverId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
|
||||||
|
userRepository.findById(callerId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
|
||||||
|
|
||||||
|
pendingCalls.remove(receiverId);
|
||||||
|
String receiverName = receiver.getDisplayName() != null ? receiver.getDisplayName() : receiver.getEmail();
|
||||||
|
Map<String, String> payload = new HashMap<>(getCallState(channelName));
|
||||||
|
payload.put("type", "CALL_ACCEPTED");
|
||||||
|
payload.put("status", "ACCEPTED");
|
||||||
|
payload.put("callerId", String.valueOf(callerId));
|
||||||
|
payload.put("receiverId", String.valueOf(receiverId));
|
||||||
|
payload.put("receiverName", receiverName);
|
||||||
|
payload.put("channelName", channelName != null ? channelName : "");
|
||||||
|
payload.put("acceptedBy", String.valueOf(receiverId));
|
||||||
|
payload.put("acceptedAt", String.valueOf(System.currentTimeMillis()));
|
||||||
|
|
||||||
|
acceptedCalls.put(callerId, payload);
|
||||||
|
if (channelName != null && !channelName.isBlank()) {
|
||||||
|
callStates.put(channelName, payload);
|
||||||
|
}
|
||||||
|
locationBroadcaster.broadcastCall(callerId, payload);
|
||||||
|
locationBroadcaster.broadcastCall(receiverId, payload);
|
||||||
|
log.info("[CALL] Call accepted | caller={} receiver={} channel={}", callerId, receiverId, channelName);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getPendingCall(Long receiverId) {
|
||||||
|
return pendingCalls.get(receiverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearPendingCall(Long receiverId) {
|
||||||
|
pendingCalls.remove(receiverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getAcceptedCall(Long callerId) {
|
||||||
|
return acceptedCalls.get(callerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearAcceptedCall(Long callerId) {
|
||||||
|
acceptedCalls.remove(callerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getCallState(String channelName) {
|
||||||
|
if (channelName == null || channelName.isBlank()) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
return callStates.getOrDefault(channelName, new HashMap<>());
|
||||||
|
}
|
||||||
|
|
||||||
public void notifyCallEnded(Long callerId, Long otherId) {
|
public void notifyCallEnded(Long callerId, Long otherId) {
|
||||||
|
notifyCallEnded(callerId, otherId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void notifyCallEnded(Long callerId, Long otherId, String channelName) {
|
||||||
if (otherId == null) {
|
if (otherId == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearPendingCall(otherId);
|
||||||
|
clearPendingCall(callerId);
|
||||||
|
clearAcceptedCall(callerId);
|
||||||
|
clearAcceptedCall(otherId);
|
||||||
|
|
||||||
|
String resolvedChannel = channelName;
|
||||||
|
if (resolvedChannel == null || resolvedChannel.isBlank()) {
|
||||||
|
resolvedChannel = findActiveChannel(callerId, otherId);
|
||||||
|
}
|
||||||
|
Map<String, String> payload = new HashMap<>(getCallState(resolvedChannel));
|
||||||
|
payload.put("type", "CALL_ENDED");
|
||||||
|
payload.put("status", "ENDED");
|
||||||
|
payload.put("callerId", String.valueOf(callerId));
|
||||||
|
payload.put("otherId", String.valueOf(otherId));
|
||||||
|
payload.put("channelName", resolvedChannel != null ? resolvedChannel : "");
|
||||||
|
payload.put("endedBy", String.valueOf(callerId));
|
||||||
|
payload.put("endedAt", String.valueOf(System.currentTimeMillis()));
|
||||||
|
if (resolvedChannel != null && !resolvedChannel.isBlank()) {
|
||||||
|
callStates.put(resolvedChannel, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
locationBroadcaster.broadcastCall(otherId, payload);
|
||||||
|
locationBroadcaster.broadcastCall(callerId, payload);
|
||||||
|
|
||||||
userRepository.findById(otherId).ifPresent(other -> {
|
userRepository.findById(otherId).ifPresent(other -> {
|
||||||
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
|
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fcmService.sendToToken(
|
fcmService.sendToToken(
|
||||||
other.getFcmToken(),
|
other.getFcmToken(),
|
||||||
"Panggilan Berakhir",
|
"Panggilan Berakhir",
|
||||||
"Panggilan telah berakhir",
|
"Panggilan telah berakhir",
|
||||||
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
|
payload
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String findActiveChannel(Long userA, Long userB) {
|
||||||
|
String a = String.valueOf(userA);
|
||||||
|
String b = String.valueOf(userB);
|
||||||
|
return callStates.entrySet().stream()
|
||||||
|
.filter(e -> a.equals(e.getValue().get("callerId")) && b.equals(e.getValue().get("receiverId"))
|
||||||
|
|| b.equals(e.getValue().get("callerId")) && a.equals(e.getValue().get("receiverId")))
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,50 +1,130 @@
|
|||||||
package com.walkguide.service;
|
package com.walkguide.service;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import com.google.cloud.Timestamp;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import com.google.cloud.firestore.Firestore;
|
||||||
|
import com.google.firebase.FirebaseApp;
|
||||||
|
import com.google.firebase.cloud.FirestoreClient;
|
||||||
|
import com.google.firebase.messaging.AndroidConfig;
|
||||||
|
import com.google.firebase.messaging.AndroidNotification;
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging;
|
||||||
|
import com.google.firebase.messaging.Message;
|
||||||
|
import com.google.firebase.messaging.Notification;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FCM Service untuk push notification.
|
* FCM Service untuk push notification dan audit notifikasi ke Firestore.
|
||||||
* Saat ini dalam mode LOG-ONLY agar tidak butuh Firebase credentials dulu.
|
* Jika Firebase credential belum tersedia, service tetap aman berjalan dalam mode log-only.
|
||||||
* Untuk enable FCM nyata: uncomment bagian Firebase dan tambah dependency Firebase Admin SDK.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class FcmService {
|
public class FcmService {
|
||||||
|
|
||||||
public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) {
|
private static final Logger log = LoggerFactory.getLogger(FcmService.class);
|
||||||
if (fcmToken == null || fcmToken.isBlank()) {
|
|
||||||
log.warn("[FCM] Token kosong, skip notifikasi: {} - {}", title, body);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// LOG ONLY untuk sekarang
|
|
||||||
log.info("[FCM] TO={} | TITLE={} | BODY={} | DATA={}", fcmToken, title, body, data);
|
|
||||||
|
|
||||||
// TODO: uncomment ini setelah tambah Firebase Admin SDK ke pom.xml
|
public void sendToToken(String fcmToken, String title, String body, Map<String, String> data) {
|
||||||
// dan taruh google-services-admin.json di src/main/resources/firebase/
|
sendInternal(fcmToken, title, body, data, false);
|
||||||
//
|
|
||||||
// try {
|
|
||||||
// Message message = Message.builder()
|
|
||||||
// .setToken(fcmToken)
|
|
||||||
// .setNotification(Notification.builder().setTitle(title).setBody(body).build())
|
|
||||||
// .putAllData(data != null ? data : Map.of())
|
|
||||||
// .setAndroidConfig(AndroidConfig.builder()
|
|
||||||
// .setPriority(AndroidConfig.Priority.HIGH)
|
|
||||||
// .build())
|
|
||||||
// .build();
|
|
||||||
// String response = FirebaseMessaging.getInstance().send(message);
|
|
||||||
// log.info("[FCM] Sent successfully: {}", response);
|
|
||||||
// } catch (FirebaseMessagingException e) {
|
|
||||||
// log.error("[FCM] Failed to send: {}", e.getMessage());
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendHighPriority(String fcmToken, String title, String body, Map<String, String> data) {
|
public void sendHighPriority(String fcmToken, String title, String body, Map<String, String> data) {
|
||||||
// SOS dan incoming call pakai ini - sama untuk sekarang
|
sendInternal(fcmToken, title, body, data, true);
|
||||||
sendToToken(fcmToken, title, body, data);
|
}
|
||||||
|
|
||||||
|
@Value("${firebase.notifications-collection:notifications}")
|
||||||
|
private String notificationsCollection;
|
||||||
|
|
||||||
|
private void sendInternal(String fcmToken, String title, String body, Map<String, String> data, boolean highPriority) {
|
||||||
|
Map<String, String> safeData = data != null ? data : Map.of();
|
||||||
|
String status = "SKIPPED";
|
||||||
|
String messageId = null;
|
||||||
|
|
||||||
|
if (fcmToken == null || fcmToken.isBlank()) {
|
||||||
|
log.warn("[FCM] Token kosong, skip notifikasi: {} - {}", title, body);
|
||||||
|
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FirebaseApp.getApps().isEmpty()) {
|
||||||
|
status = "LOG_ONLY";
|
||||||
|
log.info("[FCM] LOG_ONLY TO={} | TITLE={} | BODY={} | DATA={}",
|
||||||
|
maskToken(fcmToken), title, body, safeData);
|
||||||
|
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
AndroidConfig.Priority priority = highPriority
|
||||||
|
? AndroidConfig.Priority.HIGH
|
||||||
|
: AndroidConfig.Priority.NORMAL;
|
||||||
|
|
||||||
|
AndroidNotification androidNotification = AndroidNotification.builder()
|
||||||
|
.setChannelId(highPriority ? "walkguide_urgent" : "walkguide_alerts")
|
||||||
|
.setPriority(highPriority
|
||||||
|
? AndroidNotification.Priority.MAX
|
||||||
|
: AndroidNotification.Priority.DEFAULT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Message message = Message.builder()
|
||||||
|
.setToken(fcmToken)
|
||||||
|
.setNotification(Notification.builder()
|
||||||
|
.setTitle(title != null ? title : "WalkGuide")
|
||||||
|
.setBody(body != null ? body : "")
|
||||||
|
.build())
|
||||||
|
.putAllData(safeData)
|
||||||
|
.setAndroidConfig(AndroidConfig.builder()
|
||||||
|
.setPriority(priority)
|
||||||
|
.setNotification(androidNotification)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
messageId = FirebaseMessaging.getInstance().send(message);
|
||||||
|
status = "SENT";
|
||||||
|
log.info("[FCM] Sent {} notification successfully: {}", highPriority ? "high-priority" : "normal", messageId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
status = "FAILED";
|
||||||
|
log.error("[FCM] Failed to send notification: {}", e.getMessage());
|
||||||
|
} finally {
|
||||||
|
saveNotificationAudit(fcmToken, title, body, safeData, highPriority, status, messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveNotificationAudit(String fcmToken, String title, String body, Map<String, String> data,
|
||||||
|
boolean highPriority, String status, String messageId) {
|
||||||
|
if (FirebaseApp.getApps().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Firestore firestore = FirestoreClient.getFirestore();
|
||||||
|
Map<String, Object> doc = new HashMap<>();
|
||||||
|
doc.put("title", title);
|
||||||
|
doc.put("body", body);
|
||||||
|
doc.put("type", data.getOrDefault("type", "GENERAL"));
|
||||||
|
doc.put("data", data);
|
||||||
|
doc.put("priority", highPriority ? "HIGH" : "NORMAL");
|
||||||
|
doc.put("status", status);
|
||||||
|
doc.put("messageId", messageId);
|
||||||
|
doc.put("recipientTokenMasked", maskToken(fcmToken));
|
||||||
|
doc.put("createdAt", Timestamp.ofTimeSecondsAndNanos(Instant.now().getEpochSecond(), 0));
|
||||||
|
|
||||||
|
firestore.collection(notificationsCollection).add(doc).get();
|
||||||
|
log.debug("[FIRESTORE] Notification audit saved | type={} status={}", doc.get("type"), status);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[FIRESTORE] Notification audit skipped: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String maskToken(String token) {
|
||||||
|
if (token == null || token.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int visible = Math.min(6, token.length());
|
||||||
|
return "***" + token.substring(token.length() - visible);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import com.walkguide.enums.*;
|
|||||||
import com.walkguide.exception.PairingException;
|
import com.walkguide.exception.PairingException;
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
import com.walkguide.repository.*;
|
import com.walkguide.repository.*;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@ -18,7 +17,6 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class PairingService {
|
public class PairingService {
|
||||||
|
|
||||||
private final PairingRelationRepository pairingRelationRepository;
|
private final PairingRelationRepository pairingRelationRepository;
|
||||||
@ -34,6 +32,22 @@ public class PairingService {
|
|||||||
private static final int PAIRING_CODE_TTL_MINUTES = 15;
|
private static final int PAIRING_CODE_TTL_MINUTES = 15;
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
public PairingService(PairingRelationRepository pairingRelationRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
VoiceCommandConfigRepository voiceCommandConfigRepository,
|
||||||
|
HardwareShortcutRepository hardwareShortcutRepository,
|
||||||
|
AiConfigRepository aiConfigRepository,
|
||||||
|
ActivityLogService activityLogService,
|
||||||
|
FcmService fcmService) {
|
||||||
|
this.pairingRelationRepository = pairingRelationRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.voiceCommandConfigRepository = voiceCommandConfigRepository;
|
||||||
|
this.hardwareShortcutRepository = hardwareShortcutRepository;
|
||||||
|
this.aiConfigRepository = aiConfigRepository;
|
||||||
|
this.activityLogService = activityLogService;
|
||||||
|
this.fcmService = fcmService;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public PairingCodeResponse getOrCreatePairingCode(Long userId) {
|
public PairingCodeResponse getOrCreatePairingCode(Long userId) {
|
||||||
User user = userRepository.findById(userId)
|
User user = userRepository.findById(userId)
|
||||||
@ -69,7 +83,6 @@ public class PairingService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
|
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
|
||||||
// Guardian tidak boleh punya pairing ACTIVE atau PENDING
|
|
||||||
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
|
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
|
||||||
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
|
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
|
||||||
}
|
}
|
||||||
@ -88,6 +101,52 @@ public class PairingService {
|
|||||||
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
|
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var existingGuardianPairing = pairingRelationRepository.findByGuardian_Id(guardianId);
|
||||||
|
if (existingGuardianPairing.isPresent()) {
|
||||||
|
PairingRelation existing = existingGuardianPairing.get();
|
||||||
|
if (existing.getStatus() == PairingStatus.ACTIVE) {
|
||||||
|
if (existing.getUser().getId().equals(user.getId())) {
|
||||||
|
return buildStatus(existing, guardian, existing.getUser(), "GUARDIAN");
|
||||||
|
}
|
||||||
|
throw new PairingException(
|
||||||
|
"Guardian sudah pairing aktif dengan User lain. Unpair dulu sebelum invite User baru.");
|
||||||
|
}
|
||||||
|
if (existing.getStatus() == PairingStatus.PENDING) {
|
||||||
|
if (existing.getUser().getId().equals(user.getId())) {
|
||||||
|
sendPairingInviteNotification(existing, guardian, user);
|
||||||
|
return buildStatus(existing, guardian, user, "GUARDIAN");
|
||||||
|
}
|
||||||
|
throw new PairingException(
|
||||||
|
"Guardian masih punya undangan pairing yang menunggu respons User.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingUserPairing = pairingRelationRepository.findByUser_Id(user.getId());
|
||||||
|
if (existingUserPairing.isPresent()) {
|
||||||
|
PairingRelation existing = existingUserPairing.get();
|
||||||
|
if (existing.getStatus() == PairingStatus.ACTIVE) {
|
||||||
|
throw new PairingException("User ini sudah dipair dengan Guardian lain.");
|
||||||
|
}
|
||||||
|
if (existing.getStatus() == PairingStatus.PENDING) {
|
||||||
|
if (existing.getGuardian().getId().equals(guardianId)) {
|
||||||
|
sendPairingInviteNotification(existing, guardian, user);
|
||||||
|
return buildStatus(existing, guardian, user, "GUARDIAN");
|
||||||
|
}
|
||||||
|
throw new PairingException("User ini masih punya undangan pairing dari Guardian lain.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingGuardianPairing.isPresent()) {
|
||||||
|
pairingRelationRepository.delete(existingGuardianPairing.get());
|
||||||
|
pairingRelationRepository.flush();
|
||||||
|
}
|
||||||
|
if (existingUserPairing.isPresent()
|
||||||
|
&& (existingGuardianPairing.isEmpty()
|
||||||
|
|| !existingUserPairing.get().getId().equals(existingGuardianPairing.get().getId()))) {
|
||||||
|
pairingRelationRepository.delete(existingUserPairing.get());
|
||||||
|
pairingRelationRepository.flush();
|
||||||
|
}
|
||||||
|
|
||||||
PairingRelation pairing = PairingRelation.builder()
|
PairingRelation pairing = PairingRelation.builder()
|
||||||
.guardian(guardian)
|
.guardian(guardian)
|
||||||
.user(user)
|
.user(user)
|
||||||
@ -99,11 +158,7 @@ public class PairingService {
|
|||||||
user.setPairingCodeExpiresAt(null);
|
user.setPairingCodeExpiresAt(null);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
// Kirim FCM ke user
|
sendPairingInviteNotification(pairing, guardian, user);
|
||||||
fcmService.sendToToken(user.getFcmToken(),
|
|
||||||
"Pairing Request",
|
|
||||||
"Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung",
|
|
||||||
Map.of("type", "PAIRING_INVITE", "guardianName", guardian.getDisplayName()));
|
|
||||||
|
|
||||||
activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT,
|
activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT,
|
||||||
"Guardian mengirim invite ke " + user.getDisplayName(), null);
|
"Guardian mengirim invite ke " + user.getDisplayName(), null);
|
||||||
@ -195,6 +250,13 @@ public class PairingService {
|
|||||||
// ========== PRIVATE ==========
|
// ========== PRIVATE ==========
|
||||||
|
|
||||||
private void seedDefaults(Long guardianId, Long userId) {
|
private void seedDefaults(Long guardianId, Long userId) {
|
||||||
|
voiceCommandConfigRepository.deleteByUserId(userId);
|
||||||
|
hardwareShortcutRepository.deleteByUserId(userId);
|
||||||
|
aiConfigRepository.findByUserId(userId).ifPresent(aiConfigRepository::delete);
|
||||||
|
voiceCommandConfigRepository.flush();
|
||||||
|
hardwareShortcutRepository.flush();
|
||||||
|
aiConfigRepository.flush();
|
||||||
|
|
||||||
// Voice commands default
|
// Voice commands default
|
||||||
List<VoiceCommandConfig> defaults = List.of(
|
List<VoiceCommandConfig> defaults = List.of(
|
||||||
vc(guardianId, userId, VoiceCommandKey.OPEN_WALKGUIDE, "Open Walkguide"),
|
vc(guardianId, userId, VoiceCommandKey.OPEN_WALKGUIDE, "Open Walkguide"),
|
||||||
@ -261,6 +323,15 @@ public class PairingService {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void sendPairingInviteNotification(PairingRelation pairing, User guardian, User user) {
|
||||||
|
fcmService.sendToToken(user.getFcmToken(),
|
||||||
|
"Pairing Request",
|
||||||
|
"Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung",
|
||||||
|
Map.of(
|
||||||
|
"type", "PAIRING_INVITE",
|
||||||
|
"pairingId", pairing.getId().toString(),
|
||||||
|
"guardianName", guardian.getDisplayName()));
|
||||||
|
}
|
||||||
private void assignNewPairingCode(User user, LocalDateTime now) {
|
private void assignNewPairingCode(User user, LocalDateTime now) {
|
||||||
String candidate;
|
String candidate;
|
||||||
do {
|
do {
|
||||||
@ -307,3 +378,4 @@ public class PairingService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import com.walkguide.entity.User;
|
|||||||
import com.walkguide.enums.ActivityLogType;
|
import com.walkguide.enums.ActivityLogType;
|
||||||
import com.walkguide.enums.PairingStatus;
|
import com.walkguide.enums.PairingStatus;
|
||||||
import com.walkguide.enums.SosStatus;
|
import com.walkguide.enums.SosStatus;
|
||||||
|
import com.walkguide.exception.PairingException;
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
import com.walkguide.repository.*;
|
import com.walkguide.repository.*;
|
||||||
import com.walkguide.websocket.LocationBroadcaster;
|
import com.walkguide.websocket.LocationBroadcaster;
|
||||||
@ -36,6 +37,14 @@ public class SosService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public SosEventResponse triggerSos(Long userId, SosRequest req) {
|
public SosEventResponse triggerSos(Long userId, SosRequest req) {
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
|
||||||
|
|
||||||
|
var activePairing = pairingRelationRepository
|
||||||
|
.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
|
||||||
|
.orElseThrow(() -> new PairingException(
|
||||||
|
"SOS hanya bisa dikirim setelah User terhubung dengan Guardian aktif."));
|
||||||
|
|
||||||
SosEvent sos = SosEvent.builder()
|
SosEvent sos = SosEvent.builder()
|
||||||
.userId(userId)
|
.userId(userId)
|
||||||
.triggerType(req.getTriggerType() != null ? req.getTriggerType() : "MANUAL")
|
.triggerType(req.getTriggerType() != null ? req.getTriggerType() : "MANUAL")
|
||||||
@ -46,18 +55,13 @@ public class SosService {
|
|||||||
sos = sosEventRepository.save(sos);
|
sos = sosEventRepository.save(sos);
|
||||||
final SosEvent savedSos = sos;
|
final SosEvent savedSos = sos;
|
||||||
|
|
||||||
User user = userRepository.findById(userId)
|
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
|
|
||||||
|
|
||||||
activityLogService.createLog(user, ActivityLogType.SOS_TRIGGERED,
|
activityLogService.createLog(user, ActivityLogType.SOS_TRIGGERED,
|
||||||
"SOS dikirim via " + sos.getTriggerType(), null);
|
"SOS dikirim via " + sos.getTriggerType(), null);
|
||||||
|
|
||||||
SosEventResponse sosResponse = toResponse(savedSos);
|
SosEventResponse sosResponse = toResponse(savedSos);
|
||||||
|
|
||||||
// Kirim ke Guardian via FCM (background) + WebSocket (foreground)
|
// Kirim ke Guardian via FCM (background) + WebSocket (foreground)
|
||||||
pairingRelationRepository.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
|
User guardian = activePairing.getGuardian();
|
||||||
.ifPresent(pairing -> {
|
|
||||||
User guardian = pairing.getGuardian();
|
|
||||||
String guardianFcm = guardian.getFcmToken();
|
String guardianFcm = guardian.getFcmToken();
|
||||||
String locStr = req.getLat() != null
|
String locStr = req.getLat() != null
|
||||||
? String.format("Lat:%.4f,Lng:%.4f", req.getLat(), req.getLng())
|
? String.format("Lat:%.4f,Lng:%.4f", req.getLat(), req.getLng())
|
||||||
@ -78,7 +82,6 @@ public class SosService {
|
|||||||
|
|
||||||
log.info("[SOS] Alert sent to Guardian={} for User={} | trigger={}",
|
log.info("[SOS] Alert sent to Guardian={} for User={} | trigger={}",
|
||||||
guardian.getId(), userId, savedSos.getTriggerType());
|
guardian.getId(), userId, savedSos.getTriggerType());
|
||||||
});
|
|
||||||
|
|
||||||
return sosResponse;
|
return sosResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,68 +3,49 @@ package com.walkguide.websocket;
|
|||||||
import com.walkguide.dto.response.LocationResponse;
|
import com.walkguide.dto.response.LocationResponse;
|
||||||
import com.walkguide.dto.response.NotificationResponse;
|
import com.walkguide.dto.response.NotificationResponse;
|
||||||
import com.walkguide.dto.response.SosEventResponse;
|
import com.walkguide.dto.response.SosEventResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import org.slf4j.Logger;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/**
|
import java.util.Map;
|
||||||
* Service untuk broadcast pesan real-time via WebSocket (STOMP).
|
|
||||||
*
|
|
||||||
* Dipakai oleh:
|
|
||||||
* - LocationService → broadcast GPS ke Guardian
|
|
||||||
* - SosService → broadcast SOS ke Guardian
|
|
||||||
* - NotificationService→ broadcast notif ke User
|
|
||||||
*
|
|
||||||
* PATTERN: Observer — Guardian/User subscribe ke topic,
|
|
||||||
* LocationBroadcaster push data saat ada update.
|
|
||||||
*/
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class LocationBroadcaster {
|
public class LocationBroadcaster {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LocationBroadcaster.class);
|
||||||
|
|
||||||
private final SimpMessagingTemplate messagingTemplate;
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
|
|
||||||
/**
|
public LocationBroadcaster(SimpMessagingTemplate messagingTemplate) {
|
||||||
* Broadcast lokasi GPS user ke Guardian yang subscribe.
|
this.messagingTemplate = messagingTemplate;
|
||||||
* Guardian Flutter subscribe ke: /topic/location/{userId}
|
}
|
||||||
*
|
|
||||||
* @param userId ID dari ROLE_USER (bukan guardian)
|
|
||||||
* @param location Response lokasi terbaru
|
|
||||||
*/
|
|
||||||
public void broadcastLocation(Long userId, LocationResponse location) {
|
public void broadcastLocation(Long userId, LocationResponse location) {
|
||||||
String destination = "/topic/location/" + userId;
|
String destination = "/topic/location/" + userId;
|
||||||
messagingTemplate.convertAndSend(destination, location);
|
messagingTemplate.convertAndSend(destination, location);
|
||||||
log.debug("[WS] Location broadcast → {} | lat={} lng={}",
|
log.debug("[WS] Location broadcast -> {} | lat={} lng={}",
|
||||||
destination, location.getLat(), location.getLng());
|
destination, location.getLat(), location.getLng());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast SOS event ke Guardian secara real-time.
|
|
||||||
* Guardian Flutter subscribe ke: /queue/sos/{guardianId}
|
|
||||||
*
|
|
||||||
* @param guardianId ID dari ROLE_GUARDIAN
|
|
||||||
* @param sos SOS event yang baru di-trigger
|
|
||||||
*/
|
|
||||||
public void broadcastSos(Long guardianId, SosEventResponse sos) {
|
public void broadcastSos(Long guardianId, SosEventResponse sos) {
|
||||||
String destination = "/queue/sos/" + guardianId;
|
String destination = "/queue/sos/" + guardianId;
|
||||||
messagingTemplate.convertAndSend(destination, sos);
|
messagingTemplate.convertAndSend(destination, sos);
|
||||||
log.info("[WS] SOS broadcast → {} | userId={} status={}",
|
log.info("[WS] SOS broadcast -> {} | userId={} status={}",
|
||||||
destination, sos.getUserId(), sos.getStatus());
|
destination, sos.getUserId(), sos.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast notifikasi dari Guardian ke User secara real-time.
|
|
||||||
* User Flutter subscribe ke: /queue/notif/{userId}
|
|
||||||
*
|
|
||||||
* @param userId ID dari ROLE_USER yang menerima notif
|
|
||||||
* @param notification Notifikasi yang baru dikirim Guardian
|
|
||||||
*/
|
|
||||||
public void broadcastNotification(Long userId, NotificationResponse notification) {
|
public void broadcastNotification(Long userId, NotificationResponse notification) {
|
||||||
String destination = "/queue/notif/" + userId;
|
String destination = "/queue/notif/" + userId;
|
||||||
messagingTemplate.convertAndSend(destination, notification);
|
messagingTemplate.convertAndSend(destination, notification);
|
||||||
log.debug("[WS] Notification broadcast → {} | type={}",
|
log.debug("[WS] Notification broadcast -> {} | type={}",
|
||||||
destination, notification.getNotifType());
|
destination, notification.getNotifType());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void broadcastCall(Long receiverId, Map<String, String> payload) {
|
||||||
|
String destination = "/queue/call/" + receiverId;
|
||||||
|
messagingTemplate.convertAndSend(destination, payload);
|
||||||
|
log.info("[WS] Call broadcast -> {} | type={} status={} channel={}",
|
||||||
|
destination, payload.get("type"), payload.get("status"), payload.get("channelName"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,15 @@ spring:
|
|||||||
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
||||||
username: ${DB_USERNAME:5803024001}
|
username: ${DB_USERNAME:5803024001}
|
||||||
password: ${DB_PASSWORD:pw5803024001}
|
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:false}
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
show-sql: true
|
show-sql: true
|
||||||
@ -21,8 +30,8 @@ jwt:
|
|||||||
expiration: ${JWT_EXPIRATION:86400000}
|
expiration: ${JWT_EXPIRATION:86400000}
|
||||||
|
|
||||||
agora:
|
agora:
|
||||||
app-id: ${AGORA_APP_ID:}
|
app-id: ${AGORA_APP_ID:e36c2b6592e34cfda1f6ea6432a5e68d}
|
||||||
app-certificate: ${AGORA_APP_CERTIFICATE:}
|
app-certificate: ${AGORA_APP_CERTIFICATE:70a4288475734a8c92ff8686c66cbc77}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
@ -1,11 +1,19 @@
|
|||||||
# ===== SERVER =====
|
# ===== SERVER =====
|
||||||
|
spring.config.import=optional:file:./secrets.properties
|
||||||
server.port=${SERVER_PORT:8080}
|
server.port=${SERVER_PORT:8080}
|
||||||
|
server.address=${SERVER_ADDRESS:0.0.0.0}
|
||||||
|
|
||||||
# ===== POSTGRESQL CONNECTION =====
|
# ===== POSTGRESQL CONNECTION =====
|
||||||
spring.datasource.url=${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
spring.datasource.url=${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
||||||
spring.datasource.username=${DB_USERNAME:5803024001}
|
spring.datasource.username=${DB_USERNAME:5803024001}
|
||||||
spring.datasource.password=${DB_PASSWORD:pw5803024001}
|
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,9 +35,13 @@ springdoc.swagger-ui.path=/swagger-ui.html
|
|||||||
springdoc.api-docs.path=/v3/api-docs
|
springdoc.api-docs.path=/v3/api-docs
|
||||||
|
|
||||||
# ===== AGORA RTC =====
|
# ===== AGORA RTC =====
|
||||||
agora.app-id=${AGORA_APP_ID:}
|
agora.app-id=${AGORA_APP_ID:e36c2b6592e34cfda1f6ea6432a5e68d}
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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;
|
||||||
@ -82,7 +83,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.empty()); // tidak ada guardian → skip FCM
|
.thenReturn(Optional.of(activePairing));
|
||||||
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
|
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
|
||||||
|
|
||||||
SosEventResponse result = sosService.triggerSos(2L, req);
|
SosEventResponse result = sosService.triggerSos(2L, req);
|
||||||
@ -106,7 +107,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.empty());
|
.thenReturn(Optional.of(activePairing));
|
||||||
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
|
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
|
||||||
|
|
||||||
ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class);
|
ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class);
|
||||||
@ -147,13 +148,28 @@ class SosServiceTest {
|
|||||||
SosRequest req = new SosRequest();
|
SosRequest req = new SosRequest();
|
||||||
req.setTriggerType("MANUAL");
|
req.setTriggerType("MANUAL");
|
||||||
|
|
||||||
when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos);
|
|
||||||
when(userRepository.findById(99L)).thenReturn(Optional.empty());
|
when(userRepository.findById(99L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> sosService.triggerSos(99L, req))
|
assertThatThrownBy(() -> sosService.triggerSos(99L, req))
|
||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("triggerSos - tanpa pairing aktif: harus throw PairingException dan tidak simpan SOS")
|
||||||
|
void triggerSos_unpaired_shouldThrowPairingException() {
|
||||||
|
SosRequest req = new SosRequest();
|
||||||
|
req.setTriggerType("MANUAL");
|
||||||
|
|
||||||
|
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
||||||
|
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> sosService.triggerSos(2L, req))
|
||||||
|
.isInstanceOf(PairingException.class)
|
||||||
|
.hasMessageContaining("Guardian aktif");
|
||||||
|
verify(sosEventRepository, never()).save(any(SosEvent.class));
|
||||||
|
}
|
||||||
|
|
||||||
// ===== acknowledgeSos TESTS =====
|
// ===== acknowledgeSos TESTS =====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -5,6 +5,10 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (file("google-services.json").exists()) {
|
||||||
|
apply(plugin = "com.google.gms.google-services")
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.walkguide_app"
|
namespace = "com.example.walkguide_app"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
org.gradle.parallel=false
|
||||||
|
org.gradle.daemon=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
kotlin.incremental=false
|
kotlin.incremental=false
|
||||||
@ -21,6 +21,7 @@ plugins {
|
|||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.9.1" apply false
|
id("com.android.application") version "8.9.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
|
id("com.google.gms.google-services") version "4.4.2" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@ -4,13 +4,14 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
|
|
||||||
import 'app_cubit.dart';
|
import 'app_cubit.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
|
import '../core/theme/app_colors.dart';
|
||||||
|
|
||||||
class WalkGuideApp extends StatelessWidget {
|
class WalkGuideApp extends StatelessWidget {
|
||||||
const WalkGuideApp({super.key});
|
const WalkGuideApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const seed = Color(0xFF1A56DB);
|
const seed = AppColors.primary;
|
||||||
|
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (_) => AppCubit(),
|
create: (_) => AppCubit(),
|
||||||
@ -23,9 +24,15 @@ class WalkGuideApp extends StatelessWidget {
|
|||||||
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: GoogleFonts.interTextTheme().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(),
|
||||||
@ -35,16 +42,41 @@ class WalkGuideApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
backgroundColor: Color(0xFFF4F7FB),
|
backgroundColor: AppColors.surface,
|
||||||
foregroundColor: Color(0xFF0F172A),
|
foregroundColor: AppColors.text,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
),
|
),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
elevation: 0,
|
||||||
|
color: AppColors.surfaceRaised,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(8),
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
navigationBarTheme: NavigationBarThemeData(
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
height: 76,
|
height: 76,
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.96),
|
backgroundColor: Colors.white,
|
||||||
indicatorColor: const Color(0xFFE0E7FF),
|
indicatorColor: const Color(0xFFDDEAFE),
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||||
(states) => TextStyle(
|
(states) => TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@ -61,7 +93,7 @@ class WalkGuideApp extends StatelessWidget {
|
|||||||
minimumSize: const Size(0, 50),
|
minimumSize: const Size(0, 50),
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -70,27 +102,38 @@ class WalkGuideApp extends StatelessWidget {
|
|||||||
minimumSize: const Size(0, 50),
|
minimumSize: const Size(0, 50),
|
||||||
foregroundColor: seed,
|
foregroundColor: seed,
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||||
side: const BorderSide(color: Color(0xFFCBD5E1)),
|
side: const BorderSide(color: AppColors.border),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
backgroundColor: AppColors.text,
|
||||||
|
contentTextStyle: GoogleFonts.inter(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFFF8FAFC),
|
fillColor: Colors.white,
|
||||||
contentPadding:
|
contentPadding:
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(10),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(10),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(10),
|
||||||
borderSide: const BorderSide(color: seed, width: 1.5),
|
borderSide: const BorderSide(color: seed, width: 1.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import '../core/services/haptic_service.dart';
|
|||||||
import '../core/services/call_service.dart';
|
import '../core/services/call_service.dart';
|
||||||
import '../core/services/fcm_service.dart';
|
import '../core/services/fcm_service.dart';
|
||||||
import '../core/services/hardware_shortcut_listener.dart';
|
import '../core/services/hardware_shortcut_listener.dart';
|
||||||
|
import '../core/services/incoming_call_polling_service.dart';
|
||||||
import '../core/services/location_reporter_service.dart';
|
import '../core/services/location_reporter_service.dart';
|
||||||
import '../core/services/offline_queue_service.dart';
|
import '../core/services/offline_queue_service.dart';
|
||||||
import '../core/services/stt_service.dart';
|
import '../core/services/stt_service.dart';
|
||||||
@ -39,17 +40,24 @@ Future<void> initDependencies() async {
|
|||||||
sl.registerLazySingleton<SttService>(() => SttService());
|
sl.registerLazySingleton<SttService>(() => SttService());
|
||||||
sl.registerLazySingleton<HapticService>(() => HapticService());
|
sl.registerLazySingleton<HapticService>(() => HapticService());
|
||||||
sl.registerLazySingleton<ObstacleAlertStrategy>(
|
sl.registerLazySingleton<ObstacleAlertStrategy>(
|
||||||
() => TtsWithHapticObstacleAlertStrategy(sl<TtsService>(), sl<HapticService>()),
|
() => TtsWithHapticObstacleAlertStrategy(
|
||||||
|
sl<TtsService>(), sl<HapticService>()),
|
||||||
);
|
);
|
||||||
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
|
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
|
||||||
sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>()));
|
sl.registerLazySingleton<YoloDetector>(
|
||||||
|
() => YoloDetector(sl<ObstacleAnalyzer>()));
|
||||||
sl.registerLazySingleton<OfflineQueueService>(
|
sl.registerLazySingleton<OfflineQueueService>(
|
||||||
() => OfflineQueueService(sl<LocalDatabase>()),
|
() => OfflineQueueService(sl<LocalDatabase>()),
|
||||||
);
|
);
|
||||||
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
|
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
|
||||||
sl.registerLazySingleton<WebSocketService>(() => WebSocketService(sl<SecureStorage>()));
|
sl.registerLazySingleton<WebSocketService>(
|
||||||
sl.registerLazySingleton<LocationReporterService>(() => LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
|
() => WebSocketService(sl<SecureStorage>()));
|
||||||
|
sl.registerLazySingleton<LocationReporterService>(() =>
|
||||||
|
LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
|
||||||
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
|
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
|
||||||
|
sl.registerLazySingleton<IncomingCallPollingService>(
|
||||||
|
() => IncomingCallPollingService(sl<ApiClient>()),
|
||||||
|
);
|
||||||
sl.registerLazySingleton<HardwareShortcutListener>(
|
sl.registerLazySingleton<HardwareShortcutListener>(
|
||||||
() => HardwareShortcutListener(sl<ApiClient>()),
|
() => HardwareShortcutListener(sl<ApiClient>()),
|
||||||
);
|
);
|
||||||
@ -59,8 +67,10 @@ Future<void> initDependencies() async {
|
|||||||
sl.registerLazySingleton<WalkGuideRepository>(
|
sl.registerLazySingleton<WalkGuideRepository>(
|
||||||
() => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()),
|
() => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()),
|
||||||
);
|
);
|
||||||
sl.registerFactory<WalkGuideCubit>(() => WalkGuideCubit(sl<WalkGuideRepository>()));
|
sl.registerFactory<WalkGuideCubit>(
|
||||||
sl.registerLazySingleton<SosRepository>(() => SosRepositoryImpl(sl<ApiClient>()));
|
() => WalkGuideCubit(sl<WalkGuideRepository>()));
|
||||||
|
sl.registerLazySingleton<SosRepository>(
|
||||||
|
() => SosRepositoryImpl(sl<ApiClient>()));
|
||||||
sl.registerFactory<SosCubit>(() => SosCubit(sl<SosRepository>()));
|
sl.registerFactory<SosCubit>(() => SosCubit(sl<SosRepository>()));
|
||||||
sl.registerLazySingleton<NotificationRepository>(
|
sl.registerLazySingleton<NotificationRepository>(
|
||||||
() => NotificationRepositoryImpl(sl<ApiClient>(), sl<LocalDatabase>()),
|
() => NotificationRepositoryImpl(sl<ApiClient>(), sl<LocalDatabase>()),
|
||||||
|
|||||||
@ -29,7 +29,8 @@ import '../features/navigation_mode/presentation/screens/navigation_mode_screen.
|
|||||||
as nav;
|
as nav;
|
||||||
import '../features/notifications/presentation/screens/notification_screen.dart'
|
import '../features/notifications/presentation/screens/notification_screen.dart'
|
||||||
as notifications;
|
as notifications;
|
||||||
import '../features/pairing/presentation/screens/pairing_screens.dart' as pairing;
|
import '../features/pairing/presentation/screens/pairing_screens.dart'
|
||||||
|
as pairing;
|
||||||
import '../features/server_connect/server_connect_server.dart'
|
import '../features/server_connect/server_connect_server.dart'
|
||||||
as server_connect;
|
as server_connect;
|
||||||
import '../features/settings/presentation/screens/user_settings_screen.dart'
|
import '../features/settings/presentation/screens/user_settings_screen.dart'
|
||||||
@ -96,7 +97,17 @@ final GoRouter appRouter = GoRouter(
|
|||||||
builder: (_, __) => const auth_register.RegisterScreen()),
|
builder: (_, __) => const auth_register.RegisterScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/incoming-call',
|
path: '/incoming-call',
|
||||||
builder: (_, __) => const call.IncomingCallScreen()),
|
builder: (_, state) {
|
||||||
|
final extra = state.extra is Map
|
||||||
|
? Map<String, dynamic>.from(state.extra as Map)
|
||||||
|
: <String, dynamic>{};
|
||||||
|
return call.IncomingCallScreen(
|
||||||
|
callerName: extra['callerName']?.toString() ?? 'Guardian',
|
||||||
|
callerId: int.tryParse(extra['callerId']?.toString() ?? ''),
|
||||||
|
channelName: extra['channelName']?.toString(),
|
||||||
|
agoraToken: extra['agoraToken']?.toString(),
|
||||||
|
);
|
||||||
|
}),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (_, __, child) => UserShell(child: child),
|
builder: (_, __, child) => UserShell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
@ -161,6 +172,12 @@ final GoRouter appRouter = GoRouter(
|
|||||||
path: '/guardian/settings',
|
path: '/guardian/settings',
|
||||||
builder: (_, __) =>
|
builder: (_, __) =>
|
||||||
const guardian_settings.GuardianSettingsScreen()),
|
const guardian_settings.GuardianSettingsScreen()),
|
||||||
|
GoRoute(
|
||||||
|
path: '/guardian/call',
|
||||||
|
builder: (_, __) => const call.CallScreen(
|
||||||
|
targetLabel: 'User',
|
||||||
|
returnRoute: '/guardian/dashboard',
|
||||||
|
)),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/guardian/benchmark',
|
path: '/guardian/benchmark',
|
||||||
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
||||||
|
|||||||
@ -61,7 +61,7 @@ class AppConstants {
|
|||||||
await prefs.setString(_selectedYoloModelKey, path);
|
await prefs.setString(_selectedYoloModelKey, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=...
|
// Agora App ID tetap bisa dioverride saat build: --dart-define=AGORA_APP_ID=...
|
||||||
static const String agoraAppId =
|
static const String agoraAppId = String.fromEnvironment('AGORA_APP_ID',
|
||||||
String.fromEnvironment('AGORA_APP_ID', defaultValue: '');
|
defaultValue: 'e36c2b6592e34cfda1f6ea6432a5e68d');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,6 +71,10 @@ bool _looksTechnical(String message) {
|
|||||||
'null check operator',
|
'null check operator',
|
||||||
'nosuchmethod',
|
'nosuchmethod',
|
||||||
'formatexception',
|
'formatexception',
|
||||||
|
'could not execute statement',
|
||||||
|
'duplicate key',
|
||||||
|
'constraint',
|
||||||
|
'sql [',
|
||||||
];
|
];
|
||||||
return blocked.any(lower.contains);
|
return blocked.any(lower.contains);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
|
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
import '../constants/app_constants.dart';
|
import '../constants/app_constants.dart';
|
||||||
import '../network/api_client.dart';
|
import '../network/api_client.dart';
|
||||||
@ -7,9 +10,19 @@ import '../network/api_client.dart';
|
|||||||
class CallService {
|
class CallService {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
RtcEngine? _engine;
|
RtcEngine? _engine;
|
||||||
|
VoidCallback? _onRemoteUserJoined;
|
||||||
|
VoidCallback? _onRemoteUserOffline;
|
||||||
|
|
||||||
CallService(this._apiClient);
|
CallService(this._apiClient);
|
||||||
|
|
||||||
|
void setRemoteUserJoinedCallback(VoidCallback? callback) {
|
||||||
|
_onRemoteUserJoined = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setRemoteUserOfflineCallback(VoidCallback? callback) {
|
||||||
|
_onRemoteUserOffline = callback;
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> requestToken({required int receiverId}) async {
|
Future<Map<String, dynamic>?> requestToken({required int receiverId}) async {
|
||||||
final res = await _apiClient.dio.post(
|
final res = await _apiClient.dio.post(
|
||||||
'/shared/call/token',
|
'/shared/call/token',
|
||||||
@ -41,29 +54,83 @@ class CallService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> callPairedUser({int uid = 0}) async {
|
Future<Map<String, dynamic>?> startPairedCall({int uid = 0}) async {
|
||||||
final receiverId = await getPairedReceiverId();
|
final receiverId = await getPairedReceiverId();
|
||||||
if (receiverId == null) return false;
|
if (receiverId == null) return null;
|
||||||
|
|
||||||
final tokenData = await requestToken(receiverId: receiverId);
|
final tokenData = await requestToken(receiverId: receiverId);
|
||||||
final channelName = tokenData?['channelName']?.toString();
|
final channelName = tokenData?['channelName']?.toString();
|
||||||
final token = tokenData?['token']?.toString();
|
final token = tokenData?['token']?.toString();
|
||||||
if (channelName == null || channelName.isEmpty) return false;
|
final localUid = (tokenData?['uid'] as num?)?.toInt() ?? uid;
|
||||||
|
if (channelName == null || channelName.isEmpty) return null;
|
||||||
|
|
||||||
final joined = await joinChannel(
|
final joined = await joinChannel(
|
||||||
channelName: channelName,
|
channelName: channelName,
|
||||||
token: token,
|
token: token,
|
||||||
uid: uid,
|
uid: localUid,
|
||||||
);
|
);
|
||||||
if (joined) {
|
if (!joined) return null;
|
||||||
await notifyIncomingCall(
|
|
||||||
receiverId: receiverId,
|
await notifyIncomingCall(
|
||||||
channelName: channelName,
|
receiverId: receiverId,
|
||||||
agoraToken: token,
|
channelName: channelName,
|
||||||
receiverUid: uid,
|
agoraToken: token,
|
||||||
);
|
receiverUid: 0,
|
||||||
}
|
);
|
||||||
return joined;
|
|
||||||
|
return {
|
||||||
|
'receiverId': receiverId,
|
||||||
|
'channelName': channelName,
|
||||||
|
'token': token,
|
||||||
|
'uid': localUid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> callPairedUser({int uid = 0}) async {
|
||||||
|
return await startPairedCall(uid: uid) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> acceptIncomingCall({
|
||||||
|
required int callerId,
|
||||||
|
required String channelName,
|
||||||
|
}) async {
|
||||||
|
await _apiClient.dio.post('/shared/call/accept', data: {
|
||||||
|
'callerId': callerId.toString(),
|
||||||
|
'channelName': channelName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getAcceptedCall() async {
|
||||||
|
final res = await _apiClient.dio.get('/shared/call/accepted');
|
||||||
|
final data = res.data['data'];
|
||||||
|
return data is Map ? Map<String, dynamic>.from(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> getCallState(String? channelName) async {
|
||||||
|
if (channelName == null || channelName.isEmpty) return null;
|
||||||
|
final res = await _apiClient.dio.get(
|
||||||
|
'/shared/call/state',
|
||||||
|
queryParameters: {'channelName': channelName},
|
||||||
|
);
|
||||||
|
final data = res.data['data'];
|
||||||
|
return data is Map ? Map<String, dynamic>.from(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearAcceptedCall() async {
|
||||||
|
await _apiClient.dio.delete('/shared/call/accepted');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPendingCall() async {
|
||||||
|
await _apiClient.dio.delete('/shared/call/pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> endCall(int? otherId, {String? channelName}) async {
|
||||||
|
if (otherId == null) return;
|
||||||
|
await _apiClient.dio.post('/shared/call/end', data: {
|
||||||
|
'otherId': otherId.toString(),
|
||||||
|
if (channelName != null && channelName.isNotEmpty)
|
||||||
|
'channelName': channelName,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> joinChannel({
|
Future<bool> joinChannel({
|
||||||
@ -71,32 +138,94 @@ class CallService {
|
|||||||
String? token,
|
String? token,
|
||||||
int uid = 0,
|
int uid = 0,
|
||||||
}) async {
|
}) async {
|
||||||
|
final joinCompleter = Completer<bool>();
|
||||||
try {
|
try {
|
||||||
if (AppConstants.agoraAppId.isEmpty) {
|
if (AppConstants.agoraAppId.isEmpty) {
|
||||||
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
|
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!await _ensureMicrophonePermission()) {
|
||||||
|
debugPrint('Agora join skipped: microphone permission denied');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
_engine ??= createAgoraRtcEngine();
|
_engine ??= createAgoraRtcEngine();
|
||||||
await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId));
|
await _engine!.initialize(
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
onError: (type, msg) {
|
||||||
|
debugPrint('Agora error: $type $msg');
|
||||||
|
if (!joinCompleter.isCompleted) joinCompleter.complete(false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _engine!.setChannelProfile(
|
||||||
|
ChannelProfileType.channelProfileCommunication,
|
||||||
|
);
|
||||||
await _engine!.enableAudio();
|
await _engine!.enableAudio();
|
||||||
|
await _engine!.enableLocalAudio(true);
|
||||||
|
await _engine!.muteLocalAudioStream(false);
|
||||||
|
await _engine!.setEnableSpeakerphone(true);
|
||||||
await _engine!.joinChannel(
|
await _engine!.joinChannel(
|
||||||
token: token ?? '',
|
token: token ?? '',
|
||||||
channelId: channelName,
|
channelId: channelName,
|
||||||
uid: uid,
|
uid: uid,
|
||||||
options: const ChannelMediaOptions(),
|
options: const ChannelMediaOptions(
|
||||||
|
channelProfile: ChannelProfileType.channelProfileCommunication,
|
||||||
|
clientRoleType: ClientRoleType.clientRoleBroadcaster,
|
||||||
|
publishMicrophoneTrack: true,
|
||||||
|
autoSubscribeAudio: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return joinCompleter.future.timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () {
|
||||||
|
debugPrint('Agora join timeout for channel $channelName');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return true;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Agora join skipped: $e');
|
debugPrint('Agora join skipped: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _ensureMicrophonePermission() async {
|
||||||
|
if (kIsWeb) return true;
|
||||||
|
final status = await Permission.microphone.request();
|
||||||
|
return status.isGranted || status.isLimited;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMuted(bool muted) async {
|
||||||
|
await _engine?.muteLocalAudioStream(muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSpeakerEnabled(bool enabled) async {
|
||||||
|
await _engine?.setEnableSpeakerphone(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> leave() async {
|
Future<void> leave() async {
|
||||||
|
_onRemoteUserJoined = null;
|
||||||
|
_onRemoteUserOffline = null;
|
||||||
await _engine?.leaveChannel();
|
await _engine?.leaveChannel();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
|
_onRemoteUserJoined = null;
|
||||||
|
_onRemoteUserOffline = null;
|
||||||
await _engine?.release();
|
await _engine?.release();
|
||||||
_engine = null;
|
_engine = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
class FcmService {
|
class FcmService {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||||
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
|
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||||
|
FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
FcmService(this._apiClient);
|
FcmService(this._apiClient);
|
||||||
|
|
||||||
@ -18,6 +22,14 @@ class FcmService {
|
|||||||
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();
|
||||||
@ -26,7 +38,16 @@ class FcmService {
|
|||||||
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');
|
||||||
}
|
}
|
||||||
@ -42,8 +63,11 @@ class FcmService {
|
|||||||
|
|
||||||
Future<void> _showLocalNotification(RemoteMessage message) async {
|
Future<void> _showLocalNotification(RemoteMessage message) async {
|
||||||
final notification = message.notification;
|
final notification = message.notification;
|
||||||
final title = notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
|
final title =
|
||||||
final body = notification?.body ?? message.data['body']?.toString() ?? 'Ada update baru';
|
notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
|
||||||
|
final body = notification?.body ??
|
||||||
|
message.data['body']?.toString() ??
|
||||||
|
'Ada update baru';
|
||||||
await _localNotifications.show(
|
await _localNotifications.show(
|
||||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
title,
|
title,
|
||||||
@ -57,7 +81,26 @@ class FcmService {
|
|||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
payload: message.data['type']?.toString(),
|
payload: jsonEncode(message.data),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handlePayloadNavigation(Map<String, dynamic> data) {
|
||||||
|
final type = data['type']?.toString();
|
||||||
|
if (type == 'INCOMING_CALL') {
|
||||||
|
appRouter.go('/incoming-call', extra: data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == 'SOS_ALERT') {
|
||||||
|
appRouter.go('/guardian/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == 'PAIRING_INVITE' || type == 'PAIRING_RESPONSE') {
|
||||||
|
appRouter.go('/user/pairing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == 'NOTIFICATION') {
|
||||||
|
appRouter.go('/user/notifications');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../app/router.dart';
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
class IncomingCallPollingService {
|
||||||
|
IncomingCallPollingService(this._apiClient);
|
||||||
|
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
Timer? _timer;
|
||||||
|
String? _lastChannel;
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
if (_timer != null) return;
|
||||||
|
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _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: 3));
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,9 +13,9 @@ import '../storage/secure_storage.dart';
|
|||||||
/// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user.
|
/// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user.
|
||||||
///
|
///
|
||||||
/// Subscriptions yang dipakai:
|
/// Subscriptions yang dipakai:
|
||||||
/// Guardian → /topic/location/{userId} live GPS update
|
/// Guardian → /topic/location/{userId} live GPS update
|
||||||
/// Guardian → /queue/sos/{guardianId} SOS alert real-time
|
/// Guardian → /queue/sos/{guardianId} SOS alert real-time
|
||||||
/// User → /queue/notif/{userId} notifikasi dari Guardian
|
/// User → /queue/notif/{userId} notifikasi dari Guardian
|
||||||
class WebSocketService {
|
class WebSocketService {
|
||||||
final SecureStorage _storage;
|
final SecureStorage _storage;
|
||||||
|
|
||||||
@ -26,11 +26,13 @@ class WebSocketService {
|
|||||||
void Function(double lat, double lng)? _onLocation;
|
void Function(double lat, double lng)? _onLocation;
|
||||||
void Function(Map<String, dynamic> sosData)? _onSos;
|
void Function(Map<String, dynamic> sosData)? _onSos;
|
||||||
void Function(Map<String, dynamic> notifData)? _onNotif;
|
void Function(Map<String, dynamic> notifData)? _onNotif;
|
||||||
|
void Function(Map<String, dynamic> callData)? _onCall;
|
||||||
|
|
||||||
// Subscription frames (untuk unsubscribe)
|
// Subscription frames (untuk unsubscribe)
|
||||||
StompUnsubscribe? _locationUnsub;
|
StompUnsubscribe? _locationUnsub;
|
||||||
StompUnsubscribe? _sosUnsub;
|
StompUnsubscribe? _sosUnsub;
|
||||||
StompUnsubscribe? _notifUnsub;
|
StompUnsubscribe? _notifUnsub;
|
||||||
|
StompUnsubscribe? _callUnsub;
|
||||||
|
|
||||||
WebSocketService(this._storage);
|
WebSocketService(this._storage);
|
||||||
|
|
||||||
@ -88,18 +90,18 @@ class WebSocketService {
|
|||||||
await completer.future.timeout(const Duration(seconds: 5));
|
await completer.future.timeout(const Duration(seconds: 5));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[WS] Connect timeout/error: $e');
|
debugPrint('[WS] Connect timeout/error: $e');
|
||||||
// Don't throw — let dashboard work without WS
|
// Don't throw — let dashboard work without WS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe ke live GPS updates dari User.
|
/// Subscribe ke live GPS updates dari User.
|
||||||
/// Guardian panggil ini setelah connect.
|
/// Guardian panggil ini setelah connect.
|
||||||
/// [userId] = ID dari ROLE_USER yang dipair.
|
/// [userId] = ID dari ROLE_USER yang dipair.
|
||||||
void subscribeLocation(String userId,
|
void subscribeLocation(
|
||||||
void Function(double lat, double lng) callback) {
|
String userId, void Function(double lat, double lng) callback) {
|
||||||
_onLocation = callback;
|
_onLocation = callback;
|
||||||
if (_client == null || !_connected) {
|
if (_client == null || !_connected) {
|
||||||
debugPrint('[WS] subscribeLocation skipped — not connected');
|
debugPrint('[WS] subscribeLocation skipped — not connected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_locationUnsub?.call(); // unsubscribe sebelumnya jika ada
|
_locationUnsub?.call(); // unsubscribe sebelumnya jika ada
|
||||||
@ -107,8 +109,7 @@ class WebSocketService {
|
|||||||
destination: '/topic/location/$userId',
|
destination: '/topic/location/$userId',
|
||||||
callback: (frame) {
|
callback: (frame) {
|
||||||
try {
|
try {
|
||||||
final data =
|
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
|
||||||
final lat = (data['lat'] as num?)?.toDouble();
|
final lat = (data['lat'] as num?)?.toDouble();
|
||||||
final lng = (data['lng'] as num?)?.toDouble();
|
final lng = (data['lng'] as num?)?.toDouble();
|
||||||
if (lat != null && lng != null) {
|
if (lat != null && lng != null) {
|
||||||
@ -135,8 +136,7 @@ class WebSocketService {
|
|||||||
destination: '/queue/sos/$guardianId',
|
destination: '/queue/sos/$guardianId',
|
||||||
callback: (frame) {
|
callback: (frame) {
|
||||||
try {
|
try {
|
||||||
final data =
|
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
|
||||||
_onSos?.call(data);
|
_onSos?.call(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[WS] SOS parse error: $e');
|
debugPrint('[WS] SOS parse error: $e');
|
||||||
@ -147,7 +147,7 @@ class WebSocketService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe ke notifikasi Guardian → User.
|
/// Subscribe ke notifikasi Guardian → User.
|
||||||
/// [userId] = ID dari ROLE_USER yang login.
|
/// [userId] = ID dari ROLE_USER yang login.
|
||||||
void subscribeNotification(
|
void subscribeNotification(
|
||||||
void Function(Map<String, dynamic> notifData) callback) {
|
void Function(Map<String, dynamic> notifData) callback) {
|
||||||
@ -161,8 +161,7 @@ class WebSocketService {
|
|||||||
destination: '/queue/notif/$userId',
|
destination: '/queue/notif/$userId',
|
||||||
callback: (frame) {
|
callback: (frame) {
|
||||||
try {
|
try {
|
||||||
final data =
|
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
|
||||||
_onNotif?.call(data);
|
_onNotif?.call(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[WS] Notif parse error: $e');
|
debugPrint('[WS] Notif parse error: $e');
|
||||||
@ -173,20 +172,46 @@ class WebSocketService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribe ke panggilan masuk realtime. Ini melengkapi FCM agar call tetap
|
||||||
|
/// masuk saat app foreground atau ketika FCM di app clone tidak stabil.
|
||||||
|
void subscribeCall(void Function(Map<String, dynamic> callData) callback) {
|
||||||
|
_onCall = callback;
|
||||||
|
if (_client == null || !_connected) return;
|
||||||
|
|
||||||
|
_storage.getUserId().then((userId) {
|
||||||
|
if (userId == null) return;
|
||||||
|
_callUnsub?.call();
|
||||||
|
_callUnsub = _client!.subscribe(
|
||||||
|
destination: '/queue/call/$userId',
|
||||||
|
callback: (frame) {
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||||
|
_onCall?.call(data);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[WS] Call parse error: $e');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
debugPrint('[WS] Subscribed to /queue/call/$userId');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Disconnect dan cleanup semua subscriptions.
|
/// Disconnect dan cleanup semua subscriptions.
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
_locationUnsub?.call();
|
_locationUnsub?.call();
|
||||||
_sosUnsub?.call();
|
_sosUnsub?.call();
|
||||||
_notifUnsub?.call();
|
_notifUnsub?.call();
|
||||||
|
_callUnsub?.call();
|
||||||
_locationUnsub = null;
|
_locationUnsub = null;
|
||||||
_sosUnsub = null;
|
_sosUnsub = null;
|
||||||
_notifUnsub = null;
|
_notifUnsub = null;
|
||||||
|
_callUnsub = null;
|
||||||
_client?.deactivate();
|
_client?.deactivate();
|
||||||
_client = null;
|
_client = null;
|
||||||
_connected = false;
|
_connected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy compat — lama pakai onMessage raw
|
// Legacy compat — lama pakai onMessage raw
|
||||||
void send(Object message) {
|
void send(Object message) {
|
||||||
debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.');
|
debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class AppColors {
|
class AppColors {
|
||||||
static const primary = Color(0xFF1A56DB);
|
static const primary = Color(0xFF2563EB);
|
||||||
|
static const primaryDark = Color(0xFF0F3EA8);
|
||||||
|
static const accent = Color(0xFF0891B2);
|
||||||
|
static const warning = Color(0xFFD97706);
|
||||||
static const danger = Color(0xFFDC2626);
|
static const danger = Color(0xFFDC2626);
|
||||||
static const success = Color(0xFF16A34A);
|
static const success = Color(0xFF059669);
|
||||||
static const surface = Color(0xFFF8FAFC);
|
static const surface = Color(0xFFF7FAFC);
|
||||||
|
static const surfaceRaised = Color(0xFFFFFFFF);
|
||||||
static const text = Color(0xFF0F172A);
|
static const text = Color(0xFF0F172A);
|
||||||
static const muted = Color(0xFF64748B);
|
static const muted = Color(0xFF64748B);
|
||||||
|
static const border = Color(0xFFE2E8F0);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,10 +8,13 @@ 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';
|
||||||
@ -225,7 +228,12 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
width: 56,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF1D4ED8),
|
gradient: const LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(0xFF2563EB),
|
||||||
|
Color(0xFF0891B2)
|
||||||
|
],
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.navigation_rounded,
|
child: const Icon(Icons.navigation_rounded,
|
||||||
@ -244,7 +252,32 @@ class _AuthFrame extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEFF6FF),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.shield_outlined,
|
||||||
|
size: 14, color: Color(0xFF1D4ED8)),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Secure Assistive Navigation',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF1D4ED8),
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
@ -311,9 +344,16 @@ Future<void> _saveAuthAndRoute(
|
|||||||
|
|
||||||
void _startPostLoginServices(String serverUrl) {
|
void _startPostLoginServices(String serverUrl) {
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
await sl<WebSocketService>()
|
sl<IncomingCallPollingService>().start();
|
||||||
.connect(serverUrl)
|
await sl<FcmService>().init().timeout(const Duration(seconds: 4));
|
||||||
.timeout(const Duration(seconds: 2));
|
final ws = sl<WebSocketService>();
|
||||||
|
await ws.connect(serverUrl).timeout(const Duration(seconds: 2));
|
||||||
|
ws.subscribeCall((data) {
|
||||||
|
final type = data['type']?.toString();
|
||||||
|
if (type == 'INCOMING_CALL') {
|
||||||
|
appRouter.go('/incoming-call', extra: data);
|
||||||
|
}
|
||||||
|
});
|
||||||
await sl<OfflineQueueService>()
|
await sl<OfflineQueueService>()
|
||||||
.syncPending(sl<ApiClient>())
|
.syncPending(sl<ApiClient>())
|
||||||
.timeout(const Duration(seconds: 3));
|
.timeout(const Duration(seconds: 3));
|
||||||
|
|||||||
@ -5,6 +5,7 @@ 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/errors/friendly_error.dart';
|
||||||
|
import '../../core/services/incoming_call_polling_service.dart';
|
||||||
import '../../core/storage/secure_storage.dart';
|
import '../../core/storage/secure_storage.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -70,6 +71,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sl<IncomingCallPollingService>().start();
|
||||||
// Auto-login: arahkan ke home sesuai role.
|
// Auto-login: arahkan ke home sesuai role.
|
||||||
context.go(role == 'ROLE_GUARDIAN'
|
context.go(role == 'ROLE_GUARDIAN'
|
||||||
? '/guardian/dashboard'
|
? '/guardian/dashboard'
|
||||||
|
|||||||
@ -1,11 +1,4 @@
|
|||||||
// ignore_for_file: use_build_context_synchronously, prefer_const_constructors
|
// ignore_for_file: use_build_context_synchronously
|
||||||
// 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';
|
||||||
@ -15,18 +8,23 @@ import '../../app/injection_container.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';
|
||||||
|
|
||||||
// ─── Colours ─────────────────────────────────────────────────────────────────
|
|
||||||
const _kBlue = Color(0xFF1A56DB);
|
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); // dark bg untuk call screen
|
const _kBg = Color(0xFF0F172A);
|
||||||
|
|
||||||
// ─── CallScreen ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class CallScreen extends StatefulWidget {
|
class CallScreen extends StatefulWidget {
|
||||||
const CallScreen({super.key});
|
final String targetLabel;
|
||||||
|
final String returnRoute;
|
||||||
|
|
||||||
|
const CallScreen({
|
||||||
|
super.key,
|
||||||
|
this.targetLabel = 'Guardian',
|
||||||
|
this.returnRoute = '/user/walkguide',
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CallScreen> createState() => _CallScreenState();
|
State<CallScreen> createState() => _CallScreenState();
|
||||||
@ -38,64 +36,153 @@ 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;
|
||||||
|
|
||||||
// animasi pulse saat ringing
|
late final AnimationController _pulseCtrl = AnimationController(
|
||||||
late AnimationController _pulseCtrl;
|
vsync: this,
|
||||||
late Animation<double> _pulseScale;
|
duration: const Duration(milliseconds: 1200),
|
||||||
|
)..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();
|
||||||
_pulseCtrl = AnimationController(
|
sl<TtsService>().speak('Memanggil ${widget.targetLabel}.');
|
||||||
vsync: this,
|
unawaited(_startCall());
|
||||||
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 joined = await sl<CallService>().callPairedUser();
|
final callService = sl<CallService>();
|
||||||
|
callService.setRemoteUserJoinedCallback(_markRemoteConnected);
|
||||||
|
callService.setRemoteUserOfflineCallback(() {
|
||||||
|
unawaited(_finishRemoteEnded());
|
||||||
|
});
|
||||||
|
|
||||||
if (!mounted) return;
|
try {
|
||||||
|
final invite = await callService.startPairedCall();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (invite == null) {
|
||||||
|
_failCall('Panggilan gagal. Pastikan sudah pairing dan server aktif.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (joined) {
|
_otherId = _asInt(invite['receiverId']);
|
||||||
setState(() => _phase = _CallPhase.connected);
|
_activeChannel = invite['channelName']?.toString();
|
||||||
sl<TtsService>().speak('Terhubung dengan Guardian.');
|
setState(() => _phase = _CallPhase.calling);
|
||||||
_pulseCtrl.stop();
|
sl<TtsService>().speak(
|
||||||
_startTimer();
|
'Panggilan dikirim ke ${widget.targetLabel}. Menunggu jawaban.',
|
||||||
} else {
|
);
|
||||||
setState(() => _phase = _CallPhase.failed);
|
_startAcceptedPolling();
|
||||||
sl<TtsService>()
|
_ringTimeout?.cancel();
|
||||||
.speak('Panggilan gagal. Pastikan Agora sudah dikonfigurasi.');
|
_ringTimeout = Timer(const Duration(seconds: 45), () {
|
||||||
|
if (!mounted || _phase == _CallPhase.connected) return;
|
||||||
|
_failCall('Panggilan tidak dijawab.');
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_failCall('Panggilan gagal. Server tidak merespons.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startAcceptedPolling() {
|
||||||
|
_acceptedPoll?.cancel();
|
||||||
|
_acceptedPoll = Timer.periodic(const Duration(seconds: 2), (_) async {
|
||||||
|
if (!mounted || _activeChannel == null) return;
|
||||||
|
try {
|
||||||
|
final state = await sl<CallService>()
|
||||||
|
.getCallState(_activeChannel)
|
||||||
|
.timeout(const Duration(seconds: 3));
|
||||||
|
final status = state?['status']?.toString();
|
||||||
|
if (status == 'ENDED') {
|
||||||
|
await _finishRemoteEnded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status == 'ACCEPTED') {
|
||||||
|
_markRemoteConnected();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final accepted = await sl<CallService>()
|
||||||
|
.getAcceptedCall()
|
||||||
|
.timeout(const Duration(seconds: 3));
|
||||||
|
if (accepted?['type']?.toString() != 'CALL_ACCEPTED') return;
|
||||||
|
final channel = accepted?['channelName']?.toString();
|
||||||
|
if (_activeChannel != null &&
|
||||||
|
channel != null &&
|
||||||
|
channel.isNotEmpty &&
|
||||||
|
channel != _activeChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_markRemoteConnected();
|
||||||
|
} catch (_) {
|
||||||
|
// Keep ringing; a short network hiccup should not cancel the call UI.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markRemoteConnected() {
|
||||||
|
if (!mounted || _phase == _CallPhase.connected) return;
|
||||||
|
_acceptedPoll?.cancel();
|
||||||
|
_ringTimeout?.cancel();
|
||||||
|
setState(() => _phase = _CallPhase.connected);
|
||||||
|
sl<TtsService>().speak('Terhubung dengan ${widget.targetLabel}.');
|
||||||
|
_pulseCtrl.stop();
|
||||||
|
_startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _failCall(String message) {
|
||||||
|
_acceptedPoll?.cancel();
|
||||||
|
_ringTimeout?.cancel();
|
||||||
|
sl<CallService>().setRemoteUserJoinedCallback(null);
|
||||||
|
sl<CallService>().setRemoteUserOfflineCallback(null);
|
||||||
|
setState(() => _phase = _CallPhase.failed);
|
||||||
|
_pulseCtrl.stop();
|
||||||
|
sl<TtsService>().speak(message);
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
await sl<CallService>().leave();
|
_ringTimeout?.cancel();
|
||||||
|
_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('/user/walkguide');
|
if (mounted) context.go(widget.returnRoute);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleMute() async {
|
Future<void> _toggleMute() async {
|
||||||
setState(() => _muted = !_muted);
|
setState(() => _muted = !_muted);
|
||||||
// Agora engine mute via CallService jika ada — di sini cukup state lokal
|
await sl<CallService>().setMuted(_muted);
|
||||||
// sl<CallService>().muteLocalAudio(_muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleSpeaker() {
|
Future<void> _toggleSpeaker() async {
|
||||||
setState(() => _speakerOn = !_speakerOn);
|
setState(() => _speakerOn = !_speakerOn);
|
||||||
|
await sl<CallService>().setSpeakerEnabled(_speakerOn);
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _timerLabel {
|
String get _timerLabel {
|
||||||
@ -107,183 +194,370 @@ 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 Scaffold(
|
return _CallScaffold(
|
||||||
backgroundColor: _kBg,
|
title: 'Panggilan',
|
||||||
body: SafeArea(
|
child: Column(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
const Spacer(),
|
||||||
// ── top bar ──────────────────────────────────────────────────
|
AnimatedBuilder(
|
||||||
Padding(
|
animation: _pulseCtrl,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
builder: (_, child) => Transform.scale(
|
||||||
child: Row(
|
scale: _phase == _CallPhase.calling ? _pulseScale.value : 1.0,
|
||||||
children: [
|
child: child,
|
||||||
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
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
child: _Avatar(
|
||||||
const Spacer(),
|
icon: Icons.shield_outlined,
|
||||||
|
color: _phase == _CallPhase.failed ? _kRed : _kBlue,
|
||||||
// ── avatar + name ────────────────────────────────────────────
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: _pulseCtrl,
|
|
||||||
builder: (_, child) => Transform.scale(
|
|
||||||
scale: _phase == _CallPhase.calling ? _pulseScale.value : 1.0,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
child: Container(
|
|
||||||
width: 120,
|
|
||||||
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(
|
||||||
const Text('Guardian',
|
widget.targetLabel,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 26,
|
fontSize: 30,
|
||||||
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(),
|
if (_phase == _CallPhase.connected) ...[
|
||||||
|
Row(
|
||||||
// ── controls ─────────────────────────────────────────────────
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
if (_phase == _CallPhase.connected) ...[
|
children: [
|
||||||
Row(
|
_ControlButton(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
icon: _muted ? Icons.mic_off : Icons.mic,
|
||||||
children: [
|
label: _muted ? 'Unmute' : 'Mute',
|
||||||
_ControlButton(
|
onTap: _toggleMute,
|
||||||
icon: _muted ? Icons.mic_off : Icons.mic,
|
active: _muted,
|
||||||
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),
|
|
||||||
],
|
|
||||||
|
|
||||||
if (_phase == _CallPhase.failed) ...[
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Text(
|
|
||||||
'Panggilan gagal.\nPastikan Agora App ID sudah diisi di app_constants.dart dan server backend aktif.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(color: Colors.white54, height: 1.5),
|
|
||||||
),
|
),
|
||||||
),
|
_ControlButton(
|
||||||
const SizedBox(height: 24),
|
icon: _speakerOn ? Icons.volume_up : Icons.volume_off,
|
||||||
],
|
label: _speakerOn ? 'Speaker' : 'Earpiece',
|
||||||
|
onTap: _toggleSpeaker,
|
||||||
// ── end call button ───────────────────────────────────────────
|
active: _speakerOn,
|
||||||
_EndCallButton(onTap: _endCall),
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 48),
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
],
|
],
|
||||||
),
|
if (_phase == _CallPhase.failed) ...[
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Text(
|
||||||
|
'Panggilan belum tersambung. Pastikan device lawan login, paired, dan backend aktif.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.white54, height: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
_EndCallButton(onTap: _endCall),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── IncomingCallScreen ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class IncomingCallScreen extends StatefulWidget {
|
class IncomingCallScreen extends StatefulWidget {
|
||||||
/// callerName bisa diisi dari FCM payload via extra go_router params.
|
|
||||||
/// Default 'Guardian' jika tidak ada.
|
|
||||||
final String callerName;
|
final String callerName;
|
||||||
const IncomingCallScreen({super.key, this.callerName = 'Guardian'});
|
final int? callerId;
|
||||||
|
final String? channelName;
|
||||||
|
final String? agoraToken;
|
||||||
|
|
||||||
|
const IncomingCallScreen({
|
||||||
|
super.key,
|
||||||
|
this.callerName = 'Guardian',
|
||||||
|
this.callerId,
|
||||||
|
this.channelName,
|
||||||
|
this.agoraToken,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<IncomingCallScreen> createState() => _IncomingCallScreenState();
|
State<IncomingCallScreen> createState() => _IncomingCallScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
||||||
static const _autoAnswerSeconds = 30;
|
int _secondsElapsed = 0;
|
||||||
int _countdown = _autoAnswerSeconds;
|
Timer? _callTimer;
|
||||||
Timer? _autoTimer;
|
Timer? _statePoll;
|
||||||
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() {
|
||||||
_autoTimer?.cancel();
|
_callTimer?.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)
|
|
||||||
await sl<CallService>().joinChannel(channelName: 'walkguide-call');
|
final joined = await _joinIncomingChannel();
|
||||||
if (mounted) context.go('/user/call');
|
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: 2), (_) async {
|
||||||
|
if (!mounted || _joinedChannel == null) return;
|
||||||
|
try {
|
||||||
|
final state = await sl<CallService>()
|
||||||
|
.getCallState(_joinedChannel)
|
||||||
|
.timeout(const Duration(seconds: 3));
|
||||||
|
if (state?['status']?.toString() == 'ENDED') {
|
||||||
|
await _finishIncomingRemoteEnded();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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('/user/walkguide');
|
if (mounted) context.go(await _homeRoute());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 uid = (tokenData?['uid'] as num?)?.toInt() ?? 0;
|
||||||
|
if (channelName != null && channelName.isNotEmpty) {
|
||||||
|
_joinedChannel = channelName;
|
||||||
|
return sl<CallService>().joinChannel(
|
||||||
|
channelName: channelName,
|
||||||
|
token: token,
|
||||||
|
uid: uid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final fallbackChannel = widget.channelName;
|
||||||
|
if (fallbackChannel == null || fallbackChannel.isEmpty) return false;
|
||||||
|
_joinedChannel = fallbackChannel;
|
||||||
|
return sl<CallService>().joinChannel(
|
||||||
|
channelName: fallbackChannel,
|
||||||
|
token: widget.agoraToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_connected) {
|
||||||
|
return _CallScaffold(
|
||||||
|
title: 'Terhubung',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
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(
|
||||||
|
title: 'Panggilan Masuk',
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Spacer(),
|
||||||
|
const Icon(Icons.call_received, color: _kGreen, size: 48),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Panggilan Masuk',
|
||||||
|
style: TextStyle(color: Colors.white54, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
widget.callerName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_failed
|
||||||
|
? 'Tidak bisa tersambung. Coba panggil ulang.'
|
||||||
|
: 'Tekan Terima untuk menyambungkan panggilan.',
|
||||||
|
style: TextStyle(color: _failed ? _kRed : Colors.white38),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 48),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_RoundCallButton(
|
||||||
|
icon: Icons.call_end,
|
||||||
|
color: _kRed,
|
||||||
|
label: 'Tolak',
|
||||||
|
onTap: _responding ? null : _decline,
|
||||||
|
),
|
||||||
|
_RoundCallButton(
|
||||||
|
icon: Icons.call,
|
||||||
|
color: _kGreen,
|
||||||
|
label: 'Terima',
|
||||||
|
onTap: _responding ? null : _accept,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 56),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CallScaffold extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _CallScaffold({required this.title, required this.child});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -291,55 +565,26 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Spacer(),
|
|
||||||
|
|
||||||
// ── caller info ───────────────────────────────────────────────
|
|
||||||
const Icon(Icons.call_received, color: _kGreen, size: 48),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('Panggilan Masuk',
|
|
||||||
style: TextStyle(color: Colors.white54, fontSize: 14)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(widget.callerName,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w800)),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// auto-answer countdown
|
|
||||||
Text(
|
|
||||||
'Auto-answer dalam $_countdown detik',
|
|
||||||
style: const TextStyle(color: Colors.white38, fontSize: 13),
|
|
||||||
),
|
|
||||||
|
|
||||||
const Spacer(),
|
|
||||||
|
|
||||||
// ── accept / decline ──────────────────────────────────────────
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 48),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
// Decline
|
const SizedBox(width: 48),
|
||||||
_RoundCallButton(
|
Expanded(
|
||||||
icon: Icons.call_end,
|
child: Text(
|
||||||
color: _kRed,
|
title,
|
||||||
label: 'Tolak',
|
textAlign: TextAlign.center,
|
||||||
onTap: _responding ? null : _decline,
|
style: const TextStyle(
|
||||||
),
|
color: Colors.white70,
|
||||||
// Accept
|
fontWeight: FontWeight.w600,
|
||||||
_RoundCallButton(
|
),
|
||||||
icon: Icons.call,
|
),
|
||||||
color: _kGreen,
|
|
||||||
label: 'Terima',
|
|
||||||
onTap: _responding ? null : _accept,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 48),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Expanded(child: child),
|
||||||
const SizedBox(height: 56),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -347,42 +592,73 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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('Memanggil…',
|
return const Text(
|
||||||
style: TextStyle(color: _kMuted, fontSize: 16));
|
'Memanggil...',
|
||||||
|
style: TextStyle(color: _kMuted, fontSize: 16),
|
||||||
|
);
|
||||||
case _CallPhase.connected:
|
case _CallPhase.connected:
|
||||||
return Text(timerLabel,
|
return Text(
|
||||||
style: const TextStyle(
|
timerLabel,
|
||||||
color: _kGreen, fontSize: 22, fontWeight: FontWeight.w700));
|
style: const TextStyle(
|
||||||
|
color: _kGreen,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
);
|
||||||
case _CallPhase.failed:
|
case _CallPhase.failed:
|
||||||
return const Text('Panggilan gagal',
|
return const Text(
|
||||||
style: TextStyle(color: _kRed, fontSize: 16));
|
'Panggilan gagal',
|
||||||
|
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: color, width: 3),
|
||||||
|
),
|
||||||
|
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(
|
|
||||||
{required this.icon,
|
const _ControlButton({
|
||||||
required this.label,
|
required this.icon,
|
||||||
required this.onTap,
|
required this.label,
|
||||||
this.active = false});
|
required this.onTap,
|
||||||
|
this.active = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -402,8 +678,7 @@ 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,
|
Text(label, style: const TextStyle(color: Colors.white54)),
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -412,6 +687,7 @@ 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
|
||||||
@ -421,17 +697,14 @@ class _EndCallButton extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 72,
|
width: 74,
|
||||||
height: 72,
|
height: 74,
|
||||||
decoration: const BoxDecoration(
|
decoration:
|
||||||
shape: BoxShape.circle,
|
const BoxDecoration(shape: BoxShape.circle, color: _kRed),
|
||||||
color: _kRed,
|
|
||||||
),
|
|
||||||
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',
|
const Text('Akhiri', style: TextStyle(color: Colors.white54)),
|
||||||
style: TextStyle(color: Colors.white54, fontSize: 12)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -443,32 +716,38 @@ class _RoundCallButton extends StatelessWidget {
|
|||||||
final Color color;
|
final Color color;
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
const _RoundCallButton(
|
|
||||||
{required this.icon,
|
const _RoundCallButton({
|
||||||
required this.color,
|
required this.icon,
|
||||||
required this.label,
|
required this.color,
|
||||||
this.onTap});
|
required this.label,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: onTap == null ? 0.4 : 1.0,
|
opacity: onTap == null ? 0.4 : 1,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 72,
|
width: 74,
|
||||||
height: 72,
|
height: 74,
|
||||||
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,
|
Text(label, style: const TextStyle(color: Colors.white70)),
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? _asInt(dynamic value) {
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
return int.tryParse(value?.toString() ?? '');
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -345,25 +345,58 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pending = _data?['status'] == 'PENDING';
|
final pending = _data?['status'] == 'PENDING';
|
||||||
|
final cardColor = _active
|
||||||
|
? const Color(0xFFF0FDF4)
|
||||||
|
: pending
|
||||||
|
? const Color(0xFFEFF6FF)
|
||||||
|
: const Color(0xFFFFFBEB);
|
||||||
|
final accent = _active
|
||||||
|
? const Color(0xFF059669)
|
||||||
|
: pending
|
||||||
|
? const Color(0xFF2563EB)
|
||||||
|
: const Color(0xFFD97706);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _active ? const Color(0xFFF0FDF4) : const Color(0xFFFFFBEB),
|
color: cardColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(22),
|
||||||
border: Border.all(
|
border: Border.all(color: accent.withValues(alpha: 0.28)),
|
||||||
color: _active ? const Color(0xFFBBF7D0) : const Color(0xFFFDE68A)),
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: accent.withValues(alpha: 0.10),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(_active ? Icons.link : Icons.info_outline,
|
Container(
|
||||||
color: _active
|
width: 42,
|
||||||
? const Color(0xFF16A34A)
|
height: 42,
|
||||||
: const Color(0xFFD97706)),
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_active
|
||||||
|
? Icons.verified_user_outlined
|
||||||
|
: pending
|
||||||
|
? Icons.mark_email_unread_outlined
|
||||||
|
: Icons.info_outline,
|
||||||
|
color: accent),
|
||||||
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(child: Text(_status)),
|
Expanded(
|
||||||
|
child: Text(_status,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF0F172A),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.25)),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _loading ? null : _load,
|
onPressed: _loading ? null : _load,
|
||||||
icon: _loading
|
icon: _loading
|
||||||
@ -427,33 +460,84 @@ class _Page extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: DecoratedBox(
|
||||||
padding: const EdgeInsets.all(16),
|
decoration: const BoxDecoration(
|
||||||
child: Column(
|
gradient: LinearGradient(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
begin: Alignment.topCenter,
|
||||||
children: [
|
end: Alignment.bottomCenter,
|
||||||
Row(
|
colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)],
|
||||||
children: [
|
),
|
||||||
Expanded(
|
),
|
||||||
child: Column(
|
child: Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 14, end: 0),
|
||||||
|
duration: const Duration(milliseconds: 360),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (_, offset, child) => Opacity(
|
||||||
|
opacity: (1 - offset / 14).clamp(0.0, 1.0),
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, offset), child: child),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF0F172A),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF0F172A).withValues(alpha: 0.18),
|
||||||
|
blurRadius: 28,
|
||||||
|
offset: const Offset(0, 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(title,
|
Container(
|
||||||
style: Theme.of(context)
|
width: 52,
|
||||||
.textTheme
|
height: 52,
|
||||||
.headlineSmall
|
decoration: BoxDecoration(
|
||||||
?.copyWith(fontWeight: FontWeight.w800)),
|
color:
|
||||||
if (subtitle != null)
|
const Color(0xFF38BDF8).withValues(alpha: 0.16),
|
||||||
Text(subtitle!,
|
borderRadius: BorderRadius.circular(16),
|
||||||
style: const TextStyle(color: Color(0xFF64748B))),
|
border: Border.all(color: const Color(0xFF38BDF8)),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.hub_outlined,
|
||||||
|
color: Color(0xFFBAE6FD), size: 28),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Colors.white,
|
||||||
|
)),
|
||||||
|
if (subtitle != null)
|
||||||
|
Text(subtitle!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFCBD5E1), height: 1.25)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
Expanded(child: child),
|
||||||
Expanded(child: child),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -474,24 +558,47 @@ class _InfoCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEFF6FF),
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12)),
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
border: Border.all(color: const Color(0xFFDDEAFE)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF2563EB).withValues(alpha: 0.10),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: const Offset(0, 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: const Color(0xFF1A56DB)),
|
Container(
|
||||||
const SizedBox(width: 12),
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEFF6FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: const Color(0xFF2563EB)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(title),
|
Text(title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF64748B), fontWeight: FontWeight.w700)),
|
||||||
SelectableText(value,
|
SelectableText(value,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 22, fontWeight: FontWeight.w800)),
|
fontSize: 25,
|
||||||
|
height: 1.1,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Color(0xFF0F172A))),
|
||||||
if (helper != null) ...[
|
if (helper != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 6),
|
||||||
Text(helper!,
|
Text(helper!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFF64748B), fontSize: 12)),
|
color: Color(0xFF64748B), fontSize: 12)),
|
||||||
|
|||||||
@ -9,16 +9,6 @@ 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';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ServerConnectScreen
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Gerbang pertama aplikasi.
|
|
||||||
// Muncul HANYA jika SharedPreferences tidak punya serverUrl tersimpan.
|
|
||||||
// Setelah berhasil connect, tidak akan muncul lagi kecuali user reset via
|
|
||||||
// Settings → "Change Server".
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class ServerConnectScreen extends StatefulWidget {
|
class ServerConnectScreen extends StatefulWidget {
|
||||||
const ServerConnectScreen({super.key});
|
const ServerConnectScreen({super.key});
|
||||||
|
|
||||||
@ -27,11 +17,17 @@ class ServerConnectScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
||||||
final _url = TextEditingController();
|
final _url = TextEditingController(text: 'http://127.0.0.1:8080');
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _ok = false;
|
bool _ok = false;
|
||||||
String? _message;
|
String? _message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_url.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _test() async {
|
Future<void> _test() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
@ -47,8 +43,8 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
|||||||
)).get('$clean/api/v1/auth/ping');
|
)).get('$clean/api/v1/auth/ping');
|
||||||
_ok = res.statusCode == 200 && res.data['success'] == true;
|
_ok = res.statusCode == 200 && res.data['success'] == true;
|
||||||
_message = _ok
|
_message = _ok
|
||||||
? 'Server aktif dan siap dipakai.'
|
? 'Server aktif. WalkGuide siap tersambung.'
|
||||||
: 'Server merespons dengan format tidak valid.';
|
: 'Server merespons, tetapi format ping tidak valid.';
|
||||||
},
|
},
|
||||||
onError: (message) => _message = message,
|
onError: (message) => _message = message,
|
||||||
fallback: 'Tidak bisa terhubung. Periksa URL dan jaringan.',
|
fallback: 'Tidak bisa terhubung. Periksa URL dan jaringan.',
|
||||||
@ -63,49 +59,219 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
|||||||
if (mounted) context.go('/splash');
|
if (mounted) context.go('/splash');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _useUsbUrl() => setState(() => _url.text = 'http://127.0.0.1:8080');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _AuthFrame(
|
return Scaffold(
|
||||||
title: 'Connect to Server',
|
backgroundColor: const Color(0xFFF5F8FC),
|
||||||
subtitle: 'Masukkan URL backend WalkGuide yang diberikan dosen.',
|
body: Stack(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
const Positioned.fill(
|
||||||
controller: _url,
|
child: DecoratedBox(
|
||||||
keyboardType: TextInputType.url,
|
decoration: BoxDecoration(
|
||||||
decoration: const InputDecoration(
|
gradient: LinearGradient(
|
||||||
labelText: 'Server URL',
|
begin: Alignment.topLeft,
|
||||||
hintText: 'http://server-ip:8080',
|
end: Alignment.bottomRight,
|
||||||
prefixIcon: Icon(Icons.dns_outlined),
|
colors: [
|
||||||
)),
|
Color(0xFF071226),
|
||||||
const SizedBox(height: 12),
|
Color(0xFF123D6B),
|
||||||
OutlinedButton.icon(
|
Color(0xFFF7FAFC)
|
||||||
onPressed: _loading ? null : _test,
|
],
|
||||||
icon: _loading
|
stops: [0, 0.42, 1],
|
||||||
? const SizedBox(
|
),
|
||||||
width: 18,
|
),
|
||||||
height: 18,
|
),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
: const Icon(Icons.wifi_tethering),
|
|
||||||
label: const Text('Test Connection'),
|
|
||||||
),
|
),
|
||||||
if (_message != null) ...[
|
const Positioned(
|
||||||
const SizedBox(height: 12),
|
top: -80,
|
||||||
_StatusBox(success: _ok, message: _message!),
|
right: -70,
|
||||||
],
|
child: _GlowBlob(size: 250, color: Color(0xFF38BDF8)),
|
||||||
if (_ok) ...[
|
),
|
||||||
const SizedBox(height: 12),
|
const Positioned(
|
||||||
FilledButton.icon(
|
bottom: -90,
|
||||||
onPressed: _continue,
|
left: -80,
|
||||||
icon: const Icon(Icons.arrow_forward),
|
child: _GlowBlob(size: 260, color: Color(0xFF22C55E)),
|
||||||
label: const Text('Continue')),
|
),
|
||||||
],
|
SafeArea(
|
||||||
const SizedBox(height: 24),
|
child: LayoutBuilder(
|
||||||
const Center(
|
builder: (context, constraints) {
|
||||||
child: Text(
|
final compact = constraints.maxWidth < 390;
|
||||||
'v1.0.0 | For Testing Purposes Only',
|
return SingleChildScrollView(
|
||||||
style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
padding: EdgeInsets.fromLTRB(20, compact ? 20 : 36, 20, 24),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 460),
|
||||||
|
child: TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 18, end: 0),
|
||||||
|
duration: const Duration(milliseconds: 520),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (_, y, child) => Opacity(
|
||||||
|
opacity: (1 - y / 18).clamp(0, 1),
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, y), child: child),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.96),
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.7)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 34,
|
||||||
|
offset: const Offset(0, 22),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.fromLTRB(22, 22, 22, 20),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF071226),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.navigation_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'WalkGuide Link',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
const Text(
|
||||||
|
'Connect to Server',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Sambungkan app HP ke backend Spring Boot yang sedang berjalan di laptop.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white
|
||||||
|
.withValues(alpha: 0.72),
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(22),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _url,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _test(),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Server URL',
|
||||||
|
hintText: 'http://127.0.0.1:8080',
|
||||||
|
prefixIcon: Icon(Icons.dns_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_HintChip(
|
||||||
|
icon: Icons.usb_outlined,
|
||||||
|
label: 'USB: 127.0.0.1',
|
||||||
|
onTap: _useUsbUrl,
|
||||||
|
),
|
||||||
|
const _HintChip(
|
||||||
|
icon: Icons.wifi_tethering_outlined,
|
||||||
|
label: 'Wi-Fi: IP laptop',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _loading ? null : _test,
|
||||||
|
icon: _loading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child:
|
||||||
|
CircularProgressIndicator(
|
||||||
|
strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.radar_outlined),
|
||||||
|
label: const Text('Test Connection'),
|
||||||
|
),
|
||||||
|
if (_message != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_StatusBox(
|
||||||
|
success: _ok, message: _message!),
|
||||||
|
],
|
||||||
|
if (_ok) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _continue,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.arrow_forward_rounded),
|
||||||
|
label: const Text('Continue'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
const Center(
|
||||||
|
child: Text(
|
||||||
|
'v1.0.0 | Spring Boot + Flutter',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -114,55 +280,62 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
class _GlowBlob extends StatelessWidget {
|
||||||
// Shared private widgets
|
final double size;
|
||||||
// ---------------------------------------------------------------------------
|
final Color color;
|
||||||
|
const _GlowBlob({required this.size, required this.color});
|
||||||
class _AuthFrame extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String subtitle;
|
|
||||||
final Widget child;
|
|
||||||
const _AuthFrame(
|
|
||||||
{required this.title, required this.subtitle, required this.child});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Container(
|
||||||
body: Center(
|
width: size,
|
||||||
child: SingleChildScrollView(
|
height: size,
|
||||||
padding: const EdgeInsets.all(24),
|
decoration: BoxDecoration(
|
||||||
child: ConstrainedBox(
|
shape: BoxShape.circle,
|
||||||
constraints: const BoxConstraints(maxWidth: 460),
|
color: color.withValues(alpha: 0.18),
|
||||||
child: Card(
|
boxShadow: [
|
||||||
elevation: 0,
|
BoxShadow(
|
||||||
shape: RoundedRectangleBorder(
|
color: color.withValues(alpha: 0.22),
|
||||||
borderRadius: BorderRadius.circular(18),
|
blurRadius: 60,
|
||||||
side: const BorderSide(color: Color(0xFFE2E8F0))),
|
spreadRadius: 8),
|
||||||
child: Padding(
|
],
|
||||||
padding: const EdgeInsets.all(24),
|
),
|
||||||
child: Column(
|
);
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
}
|
||||||
children: [
|
}
|
||||||
const Icon(Icons.navigation_rounded,
|
|
||||||
color: Color(0xFF1A56DB), size: 42),
|
class _HintChip extends StatelessWidget {
|
||||||
const SizedBox(height: 14),
|
final IconData icon;
|
||||||
Text(title,
|
final String label;
|
||||||
textAlign: TextAlign.center,
|
final VoidCallback? onTap;
|
||||||
style: Theme.of(context)
|
const _HintChip({required this.icon, required this.label, this.onTap});
|
||||||
.textTheme
|
|
||||||
.headlineSmall
|
@override
|
||||||
?.copyWith(fontWeight: FontWeight.w800)),
|
Widget build(BuildContext context) {
|
||||||
const SizedBox(height: 4),
|
return InkWell(
|
||||||
Text(subtitle,
|
onTap: onTap,
|
||||||
textAlign: TextAlign.center,
|
borderRadius: BorderRadius.circular(999),
|
||||||
style: const TextStyle(color: Color(0xFF64748B))),
|
child: Container(
|
||||||
const SizedBox(height: 22),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||||
child,
|
decoration: BoxDecoration(
|
||||||
],
|
color: const Color(0xFFEFF6FF),
|
||||||
),
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: const Color(0xFFBFDBFE)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14, color: const Color(0xFF1D4ED8)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF1D4ED8),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -176,31 +349,23 @@ class _StatusBox extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
final color = success ? const Color(0xFF16A34A) : const Color(0xFFDC2626);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
|
color: color.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.22)),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Row(
|
||||||
padding: const EdgeInsets.all(12),
|
children: [
|
||||||
child: Row(
|
Icon(success ? Icons.check_circle_outline : Icons.error_outline,
|
||||||
children: [
|
color: color, size: 20),
|
||||||
Icon(
|
const SizedBox(width: 8),
|
||||||
success ? Icons.check_circle_outline : Icons.error_outline,
|
Expanded(
|
||||||
color:
|
|
||||||
success ? const Color(0xFF166534) : const Color(0xFF991B1B),
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(message,
|
child: Text(message,
|
||||||
style: TextStyle(
|
style: TextStyle(color: color, fontWeight: FontWeight.w700))),
|
||||||
color: success
|
],
|
||||||
? const Color(0xFF166534)
|
|
||||||
: const Color(0xFF991B1B))),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import 'package:dio/dio.dart';
|
|||||||
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:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.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/errors/friendly_error.dart';
|
||||||
@ -139,6 +140,8 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
|
|
||||||
Future<void> _confirmAndSend() async {
|
Future<void> _confirmAndSend() async {
|
||||||
if (_sosCubit.state.phase == SosPhase.sending) return;
|
if (_sosCubit.state.phase == SosPhase.sending) return;
|
||||||
|
final paired = await _ensurePaired();
|
||||||
|
if (!paired) return;
|
||||||
|
|
||||||
// Confirmation dialog — prevents accidental tap
|
// Confirmation dialog — prevents accidental tap
|
||||||
final confirm = await showDialog<bool>(
|
final confirm = await showDialog<bool>(
|
||||||
@ -181,6 +184,35 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
await _sendSos();
|
await _sendSos();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _ensurePaired() async {
|
||||||
|
bool paired = false;
|
||||||
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
|
final res = await _api
|
||||||
|
.get('/shared/pairing/status')
|
||||||
|
.timeout(const Duration(seconds: 6));
|
||||||
|
final data = res.data['data'];
|
||||||
|
paired = data is Map && data['status'] == 'ACTIVE';
|
||||||
|
},
|
||||||
|
onError: (_) {},
|
||||||
|
fallback: 'Status pairing belum bisa dicek.',
|
||||||
|
);
|
||||||
|
if (paired) return true;
|
||||||
|
if (!mounted) return false;
|
||||||
|
sl<TtsService>().speak('SOS belum bisa dikirim. Hubungkan Guardian dulu.');
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text(
|
||||||
|
'SOS hanya bisa dikirim setelah akun terhubung dengan Guardian.'),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'Pairing',
|
||||||
|
onPressed: () => context.go('/user/pairing'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _sendSos() async {
|
Future<void> _sendSos() async {
|
||||||
await runFriendlyAction(
|
await runFriendlyAction(
|
||||||
() async {
|
() async {
|
||||||
@ -217,96 +249,98 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Header
|
// Header
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'SOS',
|
'SOS',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.headlineSmall
|
.headlineSmall
|
||||||
?.copyWith(fontWeight: FontWeight.w800),
|
?.copyWith(fontWeight: FontWeight.w800),
|
||||||
),
|
),
|
||||||
const Text(
|
const Text(
|
||||||
'Emergency alert ke Guardian',
|
'Emergency alert ke Guardian',
|
||||||
style: TextStyle(color: Color(0xFF64748B)),
|
style: TextStyle(color: Color(0xFF64748B)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: _loadHistory,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
tooltip: 'Refresh riwayat',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Active SOS banner
|
|
||||||
if (_hasActiveSos)
|
|
||||||
_ActiveSosBanner(event: _events.first, onRefresh: _loadHistory),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// SOS Button
|
|
||||||
Center(
|
|
||||||
child: sending
|
|
||||||
? const _SendingIndicator()
|
|
||||||
: AnimatedBuilder(
|
|
||||||
animation: _pulseAnim,
|
|
||||||
builder: (_, child) => Transform.scale(
|
|
||||||
scale: _hasActiveSos ? _pulseAnim.value : 1.0,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
child: _SosButton(
|
|
||||||
active: _hasActiveSos,
|
|
||||||
onPressed: _confirmAndSend,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _loadHistory,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: 'Refresh riwayat',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Active SOS banner
|
||||||
|
if (_hasActiveSos)
|
||||||
|
_ActiveSosBanner(
|
||||||
|
event: _events.first, onRefresh: _loadHistory),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// SOS Button
|
||||||
|
Center(
|
||||||
|
child: sending
|
||||||
|
? const _SendingIndicator()
|
||||||
|
: AnimatedBuilder(
|
||||||
|
animation: _pulseAnim,
|
||||||
|
builder: (_, child) => Transform.scale(
|
||||||
|
scale: _hasActiveSos ? _pulseAnim.value : 1.0,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child: _SosButton(
|
||||||
|
active: _hasActiveSos,
|
||||||
|
onPressed: _confirmAndSend,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Hint text
|
||||||
|
Text(
|
||||||
|
_hasActiveSos
|
||||||
|
? 'SOS aktif — Guardian sudah mendapat notifikasi'
|
||||||
|
: 'Tekan tombol untuk kirim SOS darurat ke Guardian',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: _hasActiveSos
|
||||||
|
? const Color(0xFFDC2626)
|
||||||
|
: const Color(0xFF64748B),
|
||||||
|
fontWeight:
|
||||||
|
_hasActiveSos ? FontWeight.w700 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
|
||||||
|
// History section
|
||||||
|
const Text(
|
||||||
|
'Riwayat SOS',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: _SosHistory(
|
||||||
|
loading: _historyLoading,
|
||||||
|
error: _historyError,
|
||||||
|
events: _events,
|
||||||
|
onRefresh: _loadHistory,
|
||||||
|
)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Hint text
|
|
||||||
Text(
|
|
||||||
_hasActiveSos
|
|
||||||
? 'SOS aktif — Guardian sudah mendapat notifikasi'
|
|
||||||
: 'Tekan tombol untuk kirim SOS darurat ke Guardian',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
color: _hasActiveSos
|
|
||||||
? const Color(0xFFDC2626)
|
|
||||||
: const Color(0xFF64748B),
|
|
||||||
fontWeight: _hasActiveSos ? FontWeight.w700 : FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
|
|
||||||
// History section
|
|
||||||
const Text(
|
|
||||||
'Riwayat SOS',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
|
|
||||||
Expanded(
|
|
||||||
child: _SosHistory(
|
|
||||||
loading: _historyLoading,
|
|
||||||
error: _historyError,
|
|
||||||
events: _events,
|
|
||||||
onRefresh: _loadHistory,
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import '../../app/injection_container.dart';
|
|||||||
import '../../core/ai/detection_export.dart';
|
import '../../core/ai/detection_export.dart';
|
||||||
import '../../core/ai/obstacle_alert_strategy.dart';
|
import '../../core/ai/obstacle_alert_strategy.dart';
|
||||||
import '../../core/errors/friendly_error.dart';
|
import '../../core/errors/friendly_error.dart';
|
||||||
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/services/location_reporter_service.dart';
|
import '../../core/services/location_reporter_service.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
import 'application/walk_guide_cubit.dart';
|
import 'application/walk_guide_cubit.dart';
|
||||||
@ -27,10 +28,15 @@ class WalkGuideScreen extends StatefulWidget {
|
|||||||
State<WalkGuideScreen> createState() => _WalkGuideScreenState();
|
State<WalkGuideScreen> createState() => _WalkGuideScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
class _WalkGuideScreenState extends State<WalkGuideScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
late final WalkGuideCubit _cubit;
|
late final WalkGuideCubit _cubit;
|
||||||
|
late final AnimationController _scanCtrl;
|
||||||
CameraController? _camera;
|
CameraController? _camera;
|
||||||
bool _processingFrame = false;
|
bool _processingFrame = false;
|
||||||
|
bool _pairingLoading = true;
|
||||||
|
bool _paired = false;
|
||||||
|
String? _pairedName;
|
||||||
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
@ -39,6 +45,11 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_cubit = sl<WalkGuideCubit>();
|
_cubit = sl<WalkGuideCubit>();
|
||||||
|
_scanCtrl = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 2200),
|
||||||
|
)..repeat();
|
||||||
|
_loadPairingStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -49,6 +60,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
}
|
}
|
||||||
_camera?.dispose();
|
_camera?.dispose();
|
||||||
sl<LocationReporterService>().stop();
|
sl<LocationReporterService>().stop();
|
||||||
|
_scanCtrl.dispose();
|
||||||
_cubit.close();
|
_cubit.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@ -56,6 +68,8 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
Future<void> _toggle() async {
|
Future<void> _toggle() async {
|
||||||
final next = !_cubit.state.active;
|
final next = !_cubit.state.active;
|
||||||
if (next) {
|
if (next) {
|
||||||
|
final paired = await _ensurePaired();
|
||||||
|
if (!paired) return;
|
||||||
await _startCamera();
|
await _startCamera();
|
||||||
await sl<LocationReporterService>().start(walkGuideActive: true);
|
await sl<LocationReporterService>().start(walkGuideActive: true);
|
||||||
await _cubit.start();
|
await _cubit.start();
|
||||||
@ -69,6 +83,48 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
|
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPairingStatus() async {
|
||||||
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
|
final res = await sl<ApiClient>()
|
||||||
|
.dio
|
||||||
|
.get('/shared/pairing/status')
|
||||||
|
.timeout(const Duration(seconds: 6));
|
||||||
|
final data = res.data['data'];
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_paired = data is Map && data['status'] == 'ACTIVE';
|
||||||
|
_pairedName = data is Map ? data['pairedWithName']?.toString() : null;
|
||||||
|
_pairingLoading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _pairingLoading = false);
|
||||||
|
},
|
||||||
|
fallback: 'Status pairing belum bisa dicek.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _ensurePaired() async {
|
||||||
|
if (_paired) return true;
|
||||||
|
await _loadPairingStatus();
|
||||||
|
if (_paired) return true;
|
||||||
|
if (!mounted) return false;
|
||||||
|
sl<TtsService>().speak('Hubungkan Guardian terlebih dahulu.');
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text(
|
||||||
|
'WalkGuide, SOS, dan panggilan aktif setelah pairing dengan Guardian.'),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'Pairing',
|
||||||
|
onPressed: () => context.go('/user/pairing'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
String _activeStatusText() {
|
String _activeStatusText() {
|
||||||
final detector = sl<YoloDetector>();
|
final detector = sl<YoloDetector>();
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
@ -86,33 +142,33 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
if (_camera != null) return;
|
if (_camera != null) return;
|
||||||
await runFriendlyAction(
|
await runFriendlyAction(
|
||||||
() async {
|
() async {
|
||||||
final cameras = await availableCameras();
|
final cameras = await availableCameras();
|
||||||
if (cameras.isEmpty) return;
|
if (cameras.isEmpty) return;
|
||||||
final backCamera = cameras.firstWhere(
|
final backCamera = cameras.firstWhere(
|
||||||
(camera) => camera.lensDirection == CameraLensDirection.back,
|
(camera) => camera.lensDirection == CameraLensDirection.back,
|
||||||
orElse: () => cameras.first,
|
orElse: () => cameras.first,
|
||||||
);
|
);
|
||||||
final controller = CameraController(
|
final controller = CameraController(
|
||||||
backCamera,
|
backCamera,
|
||||||
ResolutionPreset.medium,
|
ResolutionPreset.medium,
|
||||||
enableAudio: false,
|
enableAudio: false,
|
||||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||||
);
|
);
|
||||||
await controller.initialize();
|
await controller.initialize();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
await controller.dispose();
|
await controller.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runFriendlyAction(
|
await runFriendlyAction(
|
||||||
() => controller.startImageStream(_onCameraImage),
|
() => controller.startImageStream(_onCameraImage),
|
||||||
onError: (_) {
|
onError: (_) {
|
||||||
_cubit.updateStatus(kIsWeb
|
_cubit.updateStatus(kIsWeb
|
||||||
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
||||||
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
||||||
},
|
},
|
||||||
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
|
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
|
||||||
);
|
);
|
||||||
setState(() => _camera = controller);
|
setState(() => _camera = controller);
|
||||||
},
|
},
|
||||||
onError: (_) => _cubit.updateStatus('Camera unavailable.'),
|
onError: (_) => _cubit.updateStatus('Camera unavailable.'),
|
||||||
fallback: 'Camera unavailable.',
|
fallback: 'Camera unavailable.',
|
||||||
@ -190,7 +246,9 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
bloc: _cubit,
|
bloc: _cubit,
|
||||||
builder: (context, state) => _Page(
|
builder: (context, state) => _Page(
|
||||||
title: 'WalkGuide',
|
title: 'WalkGuide',
|
||||||
subtitle: 'On-device AI detection surface',
|
subtitle: _paired
|
||||||
|
? 'Connected to ${_pairedName ?? 'Guardian'}'
|
||||||
|
: 'Pair with Guardian to unlock live protection',
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => context.go('/user/benchmark'),
|
onPressed: () => context.go('/user/benchmark'),
|
||||||
@ -202,64 +260,52 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: _VisionPanel(
|
||||||
width: double.infinity,
|
state: state,
|
||||||
decoration: BoxDecoration(
|
camera: _camera,
|
||||||
color: const Color(0xFF0F172A),
|
scanCtrl: _scanCtrl,
|
||||||
borderRadius: BorderRadius.circular(16)),
|
paired: _paired,
|
||||||
child: Stack(
|
pairingLoading: _pairingLoading,
|
||||||
children: [
|
onPairingTap: () => context.go('/user/pairing'),
|
||||||
if (_camera != null && _camera!.value.isInitialized)
|
|
||||||
Positioned.fill(child: CameraPreview(_camera!))
|
|
||||||
else
|
|
||||||
const Center(
|
|
||||||
child: Icon(Icons.videocam_outlined,
|
|
||||||
color: Colors.white30, size: 96)),
|
|
||||||
if (state.latestDetection?.box != null)
|
|
||||||
Positioned.fill(
|
|
||||||
child: CustomPaint(
|
|
||||||
painter:
|
|
||||||
_DetectionOverlayPainter(state.latestDetection!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 16,
|
|
||||||
left: 16,
|
|
||||||
child: _Pill(
|
|
||||||
text: state.active ? 'AI ACTIVE' : 'STANDBY',
|
|
||||||
color:
|
|
||||||
state.active ? Colors.green : Colors.orange)),
|
|
||||||
if (state.latestDetection != null)
|
|
||||||
Positioned(
|
|
||||||
top: 64,
|
|
||||||
left: 16,
|
|
||||||
child: _Pill(
|
|
||||||
text:
|
|
||||||
'${ObstacleAnalyzer.spokenLabel(state.latestDetection!.label)} ${state.latestDetection!.directionName}',
|
|
||||||
color: Colors.redAccent),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
bottom: 16,
|
|
||||||
child: Text(state.status,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w700))),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
_StatusStrip(
|
||||||
|
active: state.active,
|
||||||
|
paired: _paired,
|
||||||
|
latestDetection: state.latestDetection,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
flex: 2,
|
||||||
onPressed: _toggle,
|
child: FilledButton.icon(
|
||||||
icon:
|
onPressed: _pairingLoading ? null : _toggle,
|
||||||
Icon(state.active ? Icons.stop : Icons.play_arrow),
|
icon: Icon(state.active ? Icons.stop : Icons.play_arrow),
|
||||||
label: Text(state.active ? 'Stop' : 'Start'))),
|
label: Text(state.active ? 'Stop Scan' : 'Start Scan'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_ActionSquare(
|
||||||
|
icon: Icons.sos_outlined,
|
||||||
|
color: const Color(0xFFDC2626),
|
||||||
|
onTap: () async {
|
||||||
|
if (await _ensurePaired() && context.mounted) {
|
||||||
|
context.go('/user/sos');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_ActionSquare(
|
||||||
|
icon: Icons.call_outlined,
|
||||||
|
color: const Color(0xFF059669),
|
||||||
|
onTap: () async {
|
||||||
|
if (await _ensurePaired() && context.mounted) {
|
||||||
|
context.go('/user/call');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -269,6 +315,413 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _VisionPanel extends StatelessWidget {
|
||||||
|
final WalkGuideState state;
|
||||||
|
final CameraController? camera;
|
||||||
|
final AnimationController scanCtrl;
|
||||||
|
final bool paired;
|
||||||
|
final bool pairingLoading;
|
||||||
|
final VoidCallback onPairingTap;
|
||||||
|
|
||||||
|
const _VisionPanel({
|
||||||
|
required this.state,
|
||||||
|
required this.camera,
|
||||||
|
required this.scanCtrl,
|
||||||
|
required this.paired,
|
||||||
|
required this.pairingLoading,
|
||||||
|
required this.onPairingTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cameraReady = camera != null && camera!.value.isInitialized;
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(color: Color(0xFF07111F)),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: cameraReady
|
||||||
|
? CameraPreview(camera!)
|
||||||
|
: const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
Color(0xFF07111F),
|
||||||
|
Color(0xFF0E2A3D),
|
||||||
|
Color(0xFF111827),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(child: CustomPaint(painter: _HudGridPainter())),
|
||||||
|
if (state.active)
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: scanCtrl,
|
||||||
|
builder: (_, __) => Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 28 +
|
||||||
|
(MediaQuery.of(context).size.height *
|
||||||
|
0.38 *
|
||||||
|
scanCtrl.value),
|
||||||
|
child: Container(
|
||||||
|
height: 3,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF22D3EE).withValues(alpha: 0.8),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color:
|
||||||
|
const Color(0xFF22D3EE).withValues(alpha: 0.45),
|
||||||
|
blurRadius: 22,
|
||||||
|
spreadRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.latestDetection?.box != null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _DetectionOverlayPainter(state.latestDetection!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 18,
|
||||||
|
left: 18,
|
||||||
|
right: 18,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_Pill(
|
||||||
|
text: state.active ? 'LIVE AI SCAN' : 'STANDBY',
|
||||||
|
color: state.active
|
||||||
|
? const Color(0xFF22C55E)
|
||||||
|
: const Color(0xFFF59E0B),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_Pill(
|
||||||
|
text: paired ? 'GUARDIAN LINKED' : 'PAIRING REQUIRED',
|
||||||
|
color: paired
|
||||||
|
? const Color(0xFF38BDF8)
|
||||||
|
: const Color(0xFFF97316),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: AnimatedScale(
|
||||||
|
duration: const Duration(milliseconds: 320),
|
||||||
|
scale: state.active ? 1.0 : 0.92,
|
||||||
|
child: Container(
|
||||||
|
width: 118,
|
||||||
|
height: 118,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.black.withValues(alpha: 0.28),
|
||||||
|
border: Border.all(
|
||||||
|
color: (state.active
|
||||||
|
? const Color(0xFF22D3EE)
|
||||||
|
: Colors.white)
|
||||||
|
.withValues(alpha: 0.34),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
cameraReady
|
||||||
|
? Icons.center_focus_strong
|
||||||
|
: Icons.videocam_off,
|
||||||
|
color: Colors.white.withValues(alpha: 0.68),
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!paired && !pairingLoading)
|
||||||
|
Positioned.fill(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF020617).withValues(alpha: 0.72),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 70,
|
||||||
|
height: 70,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFFBEB)
|
||||||
|
.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border:
|
||||||
|
Border.all(color: const Color(0xFFF59E0B)),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.link_off,
|
||||||
|
color: Color(0xFFFBBF24), size: 34),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
const Text(
|
||||||
|
'Guardian belum terhubung',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
const Text(
|
||||||
|
'Pairing dulu supaya SOS, panggilan, dan pemantauan live punya penerima yang jelas.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style:
|
||||||
|
TextStyle(color: Colors.white70, height: 1.35),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onPairingTap,
|
||||||
|
icon: const Icon(Icons.link),
|
||||||
|
label: const Text('Buka Pairing'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 18,
|
||||||
|
right: 18,
|
||||||
|
bottom: 18,
|
||||||
|
child: _GlassStatusBar(
|
||||||
|
status: state.status,
|
||||||
|
detection: state.latestDetection,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GlassStatusBar extends StatelessWidget {
|
||||||
|
final String status;
|
||||||
|
final DetectionResult? detection;
|
||||||
|
|
||||||
|
const _GlassStatusBar({required this.status, required this.detection});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final label = detection == null
|
||||||
|
? status
|
||||||
|
: '${ObstacleAnalyzer.spokenLabel(detection!.label)} detected ${detection!.directionName}';
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.42),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.16)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
detection == null ? Icons.sensors : Icons.warning_amber_rounded,
|
||||||
|
color: detection == null
|
||||||
|
? const Color(0xFF93C5FD)
|
||||||
|
: const Color(0xFFFBBF24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusStrip extends StatelessWidget {
|
||||||
|
final bool active;
|
||||||
|
final bool paired;
|
||||||
|
final DetectionResult? latestDetection;
|
||||||
|
|
||||||
|
const _StatusStrip({
|
||||||
|
required this.active,
|
||||||
|
required this.paired,
|
||||||
|
required this.latestDetection,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _MetricChip(
|
||||||
|
icon: Icons.health_and_safety_outlined,
|
||||||
|
label: 'Guardian',
|
||||||
|
value: paired ? 'Linked' : 'Required',
|
||||||
|
color: paired ? const Color(0xFF059669) : const Color(0xFFD97706),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _MetricChip(
|
||||||
|
icon: Icons.radar_outlined,
|
||||||
|
label: 'Detector',
|
||||||
|
value: active ? 'Scanning' : 'Idle',
|
||||||
|
color: active ? const Color(0xFF2563EB) : const Color(0xFF64748B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _MetricChip(
|
||||||
|
icon: Icons.visibility_outlined,
|
||||||
|
label: 'Obstacle',
|
||||||
|
value: latestDetection == null ? 'Clear' : 'Alert',
|
||||||
|
color: latestDetection == null
|
||||||
|
? const Color(0xFF64748B)
|
||||||
|
: const Color(0xFFDC2626),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetricChip extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _MetricChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF64748B),
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
)),
|
||||||
|
Text(value,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActionSquare extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ActionSquare({
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 54,
|
||||||
|
height: 50,
|
||||||
|
child: Icon(icon, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HudGridPainter extends CustomPainter {
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final line = Paint()
|
||||||
|
..color = Colors.white.withValues(alpha: 0.045)
|
||||||
|
..strokeWidth = 1;
|
||||||
|
for (double x = 0; x < size.width; x += 42) {
|
||||||
|
canvas.drawLine(Offset(x, 0), Offset(x, size.height), line);
|
||||||
|
}
|
||||||
|
for (double y = 0; y < size.height; y += 42) {
|
||||||
|
canvas.drawLine(Offset(0, y), Offset(size.width, y), line);
|
||||||
|
}
|
||||||
|
|
||||||
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
|
final ring = Paint()
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.2
|
||||||
|
..color = const Color(0xFF22D3EE).withValues(alpha: 0.16);
|
||||||
|
for (final radius in [64.0, 112.0, 164.0]) {
|
||||||
|
canvas.drawCircle(center, radius, ring);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
|
|
||||||
class _DetectionOverlayPainter extends CustomPainter {
|
class _DetectionOverlayPainter extends CustomPainter {
|
||||||
final DetectionResult detection;
|
final DetectionResult detection;
|
||||||
const _DetectionOverlayPainter(this.detection);
|
const _DetectionOverlayPainter(this.detection);
|
||||||
@ -356,34 +809,67 @@ class _Page extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: DecoratedBox(
|
||||||
padding: const EdgeInsets.all(16),
|
decoration: const BoxDecoration(
|
||||||
child: Column(
|
gradient: LinearGradient(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
begin: Alignment.topCenter,
|
||||||
children: [
|
end: Alignment.bottomCenter,
|
||||||
Row(
|
colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)],
|
||||||
children: [
|
),
|
||||||
Expanded(
|
),
|
||||||
child: Column(
|
child: Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||||
children: [
|
child: Column(
|
||||||
Text(title,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: Theme.of(context)
|
children: [
|
||||||
.textTheme
|
Row(
|
||||||
.headlineSmall
|
children: [
|
||||||
?.copyWith(fontWeight: FontWeight.w800)),
|
Container(
|
||||||
if (subtitle != null)
|
width: 46,
|
||||||
Text(subtitle!,
|
height: 46,
|
||||||
style: const TextStyle(color: Color(0xFF64748B))),
|
decoration: BoxDecoration(
|
||||||
],
|
color: const Color(0xFF2563EB),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color:
|
||||||
|
const Color(0xFF2563EB).withValues(alpha: 0.28),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.navigation_rounded,
|
||||||
|
color: Colors.white, size: 26),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
...?actions,
|
Expanded(
|
||||||
],
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(height: 16),
|
children: [
|
||||||
Expanded(child: child),
|
Text(title,
|
||||||
],
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: const Color(0xFF0F172A),
|
||||||
|
)),
|
||||||
|
if (subtitle != null)
|
||||||
|
Text(subtitle!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Color(0xFF64748B))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...?actions,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(child: child),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'app/injection_container.dart';
|
import 'app/injection_container.dart';
|
||||||
import 'app/app.dart';
|
import 'app/app.dart';
|
||||||
@ -8,6 +9,11 @@ import 'core/utils/init_guard.dart';
|
|||||||
|
|
||||||
List<CameraDescription> cameras = [];
|
List<CameraDescription> cameras = [];
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
|
await Firebase.initializeApp();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
@ -18,7 +24,9 @@ Future<void> main() async {
|
|||||||
[];
|
[];
|
||||||
|
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
await ignoreInitFailure(() => Firebase.initializeApp(), label: 'Firebase init');
|
await ignoreInitFailure(() => Firebase.initializeApp(),
|
||||||
|
label: 'Firebase init');
|
||||||
|
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init GetIt dependencies
|
// Init GetIt dependencies
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import '../../core/services/hardware_shortcut_listener.dart';
|
|||||||
import '../../core/services/stt_service.dart';
|
import '../../core/services/stt_service.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
import '../../core/services/voice_command_handler.dart';
|
import '../../core/services/voice_command_handler.dart';
|
||||||
|
import '../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
class UserShell extends StatefulWidget {
|
class UserShell extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@ -75,7 +76,8 @@ class _UserShellState extends State<UserShell> {
|
|||||||
if (data is! List) return;
|
if (data is! List) return;
|
||||||
final commands = data
|
final commands = data
|
||||||
.whereType<Map>()
|
.whereType<Map>()
|
||||||
.map((item) => _voiceCommandFromJson(Map<String, dynamic>.from(item)))
|
.map((item) =>
|
||||||
|
_voiceCommandFromJson(Map<String, dynamic>.from(item)))
|
||||||
.whereType<VoiceCommand>()
|
.whereType<VoiceCommand>()
|
||||||
.toList();
|
.toList();
|
||||||
if (commands.isNotEmpty) {
|
if (commands.isNotEmpty) {
|
||||||
@ -181,18 +183,40 @@ class _AppShell extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: child,
|
backgroundColor: AppColors.surface,
|
||||||
bottomNavigationBar: NavigationBar(
|
body: AnimatedSwitcher(
|
||||||
selectedIndex: _selectedIndex,
|
duration: const Duration(milliseconds: 180),
|
||||||
onDestinationSelected: (index) => context.go(items[index].route),
|
switchInCurve: Curves.easeOutCubic,
|
||||||
destinations: [
|
switchOutCurve: Curves.easeInCubic,
|
||||||
for (final item in items)
|
child: KeyedSubtree(
|
||||||
NavigationDestination(
|
key: ValueKey(location),
|
||||||
icon: Icon(item.icon),
|
child: child,
|
||||||
selectedIcon: Icon(item.selectedIcon),
|
),
|
||||||
label: item.label,
|
),
|
||||||
|
bottomNavigationBar: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: const Border(top: BorderSide(color: AppColors.border)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.06),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, -8),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
child: NavigationBar(
|
||||||
|
selectedIndex: _selectedIndex,
|
||||||
|
onDestinationSelected: (index) => context.go(items[index].route),
|
||||||
|
destinations: [
|
||||||
|
for (final item in items)
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(item.icon),
|
||||||
|
selectedIcon: Icon(item.selectedIcon),
|
||||||
|
label: item.label,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
class FeaturePage extends StatelessWidget {
|
class FeaturePage extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
@ -18,33 +20,51 @@ class FeaturePage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
TweenAnimationBuilder<double>(
|
||||||
children: [
|
tween: Tween(begin: 12, end: 0),
|
||||||
Expanded(
|
duration: const Duration(milliseconds: 360),
|
||||||
child: Column(
|
curve: Curves.easeOutCubic,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
builder: (_, offset, child) => Opacity(
|
||||||
children: [
|
opacity: (1 - offset / 12).clamp(0.0, 1.0),
|
||||||
Text(
|
child: Transform.translate(
|
||||||
title,
|
offset: Offset(0, offset),
|
||||||
style:
|
child: child,
|
||||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: const Color(0xFF0F172A),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style: const TextStyle(color: Color(0xFF64748B)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (trailing != null) trailing!,
|
),
|
||||||
],
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: AppColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.muted,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) trailing!,
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(child: child),
|
Expanded(child: child),
|
||||||
@ -77,7 +97,16 @@ class FeatureEmptyPanel extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 48, color: const Color(0xFF64748B)),
|
Container(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 36, color: AppColors.primary),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
@ -88,7 +117,7 @@ class FeatureEmptyPanel extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Color(0xFF64748B), height: 1.35),
|
style: const TextStyle(color: AppColors.muted, height: 1.35),
|
||||||
),
|
),
|
||||||
if (action != null) ...[
|
if (action != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@ -120,7 +149,7 @@ class FeatureErrorPanel extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFEF2F2),
|
color: const Color(0xFFFEF2F2),
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: const Color(0xFFFECACA)),
|
border: Border.all(color: const Color(0xFFFECACA)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
BIN
walkguide-mobile/walkguide_app/walkguide_now.png
Normal file
BIN
walkguide-mobile/walkguide_app/walkguide_now.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 452 KiB |
@ -33,6 +33,7 @@
|
|||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<script src="https://download.agora.io/sdk/release/AgoraRTC_N-4.20.2.js"></script>
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user