diff --git a/.gitignore b/.gitignore index 49b8309..ed227fa 100644 --- a/.gitignore +++ b/.gitignore @@ -40,8 +40,12 @@ build/ .env *.env +walkguide-backend/demo/secrets.properties 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) walkguide-mobile/walkguide_app/android/local.properties diff --git a/Exam Guide.md b/Exam Guide.md new file mode 100644 index 0000000..2cb48ca --- /dev/null +++ b/Exam Guide.md @@ -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. 🚀* diff --git a/walkguide-backend/demo/backend-run.err.log b/walkguide-backend/demo/backend-run.err.log new file mode 100644 index 0000000..a7fbef3 --- /dev/null +++ b/walkguide-backend/demo/backend-run.err.log @@ -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 + diff --git a/walkguide-backend/demo/backend-run.log b/walkguide-backend/demo/backend-run.log new file mode 100644 index 0000000..4af7315 --- /dev/null +++ b/walkguide-backend/demo/backend-run.log @@ -0,0 +1,2867 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -------------------------< com.walkguide:demo >------------------------- +[INFO] Building walkguide 0.0.1-SNAPSHOT +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] >>> spring-boot:3.2.5:run (default-cli) > test-compile @ demo >>> +[INFO] +[INFO] --- jacoco:0.8.11:prepare-agent (default) @ demo --- +[INFO] argLine set to "-javaagent:C:\\Users\\Robertus\\.m2\\repository\\org\\jacoco\\org.jacoco.agent\\0.8.11\\org.jacoco.agent-0.8.11-runtime.jar=destfile=C:\\COBA PROGRAM SEMESTER 4\\UAS FLUTTER FT. SPRINGBOOT dan OOD\\Final-08-Evan-Jap-Bambang-WalkGuide-AI\\walkguide-backend\\demo\\target\\jacoco.exec" +[INFO] +[INFO] --- resources:3.3.1:resources (default-resources) @ demo --- +[INFO] Copying 3 resources from src\main\resources to target\classes +[INFO] Copying 20 resources from src\main\resources to target\classes +[INFO] +[INFO] --- compiler:3.11.0:compile (default-compile) @ demo --- +[INFO] Nothing to compile - all classes are up to date +[INFO] +[INFO] --- resources:3.3.1:testResources (default-testResources) @ demo --- +[INFO] skip non existing resourceDirectory C:\COBA PROGRAM SEMESTER 4\UAS FLUTTER FT. SPRINGBOOT dan OOD\Final-08-Evan-Jap-Bambang-WalkGuide-AI\walkguide-backend\demo\src\test\resources +[INFO] +[INFO] --- compiler:3.11.0:testCompile (default-testCompile) @ demo --- +[INFO] Changes detected - recompiling the module! :source +[INFO] Compiling 28 source files with javac [debug release 21] to target\test-classes +[INFO] +[INFO] <<< spring-boot:3.2.5:run (default-cli) < test-compile @ demo <<< +[INFO] +[INFO] +[INFO] --- spring-boot:3.2.5:run (default-cli) @ demo --- +[INFO] Attaching agents: [] +Standard Commons Logging discovery in action with spring-jcl: please remove commons-logging.jar from classpath in order to avoid potential conflicts + + . ____ _ __ _ _ + /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ +( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ + \\/ ___)| |_)| | | | | || (_| | ) ) ) ) + ' |____| .__|_| |_|_| |_\__, | / / / / + =========|_|==============|___/=/_/_/_/ + :: Spring Boot :: (v3.2.5) + +2026-05-27T19:15:04.478+07:00 INFO 14960 --- [ main] com.walkguide.WalkGuideApplication : Starting WalkGuideApplication using Java 21.0.9 with PID 14960 (C:\COBA PROGRAM SEMESTER 4\UAS FLUTTER FT. SPRINGBOOT dan OOD\Final-08-Evan-Jap-Bambang-WalkGuide-AI\walkguide-backend\demo\target\classes started by Robertus in C:\COBA PROGRAM SEMESTER 4\UAS FLUTTER FT. SPRINGBOOT dan OOD\Final-08-Evan-Jap-Bambang-WalkGuide-AI\walkguide-backend\demo) +2026-05-27T19:15:04.481+07:00 DEBUG 14960 --- [ main] com.walkguide.WalkGuideApplication : Running with Spring Boot v3.2.5, Spring v6.1.6 +2026-05-27T19:15:04.482+07:00 INFO 14960 --- [ main] com.walkguide.WalkGuideApplication : The following 1 profile is active: "dev" +2026-05-27T19:15:05.632+07:00 INFO 14960 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode. +2026-05-27T19:15:05.781+07:00 INFO 14960 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 127 ms. Found 13 JPA repository interfaces. +2026-05-27T19:15:06.394+07:00 INFO 14960 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http) +2026-05-27T19:15:06.419+07:00 INFO 14960 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat] +2026-05-27T19:15:06.419+07:00 INFO 14960 --- [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.20] +2026-05-27T19:15:06.507+07:00 INFO 14960 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext +2026-05-27T19:15:06.509+07:00 INFO 14960 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1964 ms +Standard Commons Logging discovery in action with spring-jcl: please remove commons-logging.jar from classpath in order to avoid potential conflicts +2026-05-27T19:15:06.564+07:00 DEBUG 14960 --- [ main] com.walkguide.security.JwtAuthFilter : Filter 'jwtAuthFilter' configured for use +2026-05-27T19:15:06.683+07:00 INFO 14960 --- [ main] o.hibernate.jpa.internal.util.LogHelper : HHH000204: Processing PersistenceUnitInfo [name: default] +2026-05-27T19:15:06.741+07:00 INFO 14960 --- [ main] org.hibernate.Version : HHH000412: Hibernate ORM core version 6.4.4.Final +2026-05-27T19:15:06.774+07:00 INFO 14960 --- [ main] o.h.c.internal.RegionFactoryInitiator : HHH000026: Second-level cache disabled +2026-05-27T19:15:07.001+07:00 INFO 14960 --- [ main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer +2026-05-27T19:15:07.029+07:00 INFO 14960 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting... +2026-05-27T19:15:09.789+07:00 INFO 14960 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed. +2026-05-27T19:15:18.903+07:00 WARN 14960 --- [ main] org.hibernate.orm.deprecation : HHH90000025: PostgreSQLDialect does not need to be specified explicitly using 'hibernate.dialect' (remove the property setting and it will be selected by default) +2026-05-27T19:15:20.462+07:00 INFO 14960 --- [ main] o.h.e.t.j.p.i.JtaPlatformInitiator : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +2026-05-27T19:15:22.089+07:00 INFO 14960 --- [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default' +2026-05-27T19:15:22.497+07:00 INFO 14960 --- [ main] com.walkguide.config.FirebaseConfig : [FIREBASE] Firebase Admin initialized from classpath:firebase/google-services-admin.json +2026-05-27T19:15:23.016+07:00 WARN 14960 --- [ main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning +2026-05-27T19:15:23.047+07:00 WARN 14960 --- [ main] .s.s.UserDetailsServiceAutoConfiguration : + +Using generated security password: 95518d96-3ca0-4c5c-b99a-94ed7eb4c8e2 + +This generated password is for development use only. Your security configuration must be updated before running your application in production. + +2026-05-27T19:15:23.171+07:00 DEBUG 14960 --- [ main] o.s.w.s.s.s.WebSocketHandlerMapping : Patterns [/ws/**] in 'stompWebSocketHandlerMapping' +2026-05-27T19:15:23.336+07:00 INFO 14960 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@63b4a896, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@3feeab43, org.springframework.security.web.context.SecurityContextHolderFilter@5f59b6c5, org.springframework.security.web.header.HeaderWriterFilter@566dc0c3, org.springframework.web.filter.CorsFilter@6178ac9d, org.springframework.security.web.authentication.logout.LogoutFilter@5096be74, com.walkguide.security.JwtAuthFilter@113ee1ce, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@1872a7fe, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@70720b78, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@7413c41, org.springframework.security.web.session.SessionManagementFilter@535b016, org.springframework.security.web.access.ExceptionTranslationFilter@50c462b8, org.springframework.security.web.access.intercept.AuthorizationFilter@6cb59aa] +2026-05-27T19:15:23.407+07:00 DEBUG 14960 --- [ main] .WebSocketAnnotationMethodMessageHandler : + c.w.c.AuthController: + +2026-05-27T19:15:23.408+07:00 DEBUG 14960 --- [ main] .WebSocketAnnotationMethodMessageHandler : + c.w.c.CallController: + +2026-05-27T19:15:23.409+07:00 DEBUG 14960 --- [ main] .WebSocketAnnotationMethodMessageHandler : + c.w.c.GuardianController: + +2026-05-27T19:15:23.410+07:00 DEBUG 14960 --- [ main] .WebSocketAnnotationMethodMessageHandler : + c.w.c.PairingController: + +2026-05-27T19:15:23.410+07:00 DEBUG 14960 --- [ main] .WebSocketAnnotationMethodMessageHandler : + c.w.c.UserController: + +2026-05-27T19:15:23.411+07:00 DEBUG 14960 --- [ main] .WebSocketAnnotationMethodMessageHandler : + o.s.b.a.w.s.e.BasicErrorController: + +2026-05-27T19:15:23.413+07:00 DEBUG 14960 --- [ main] .WebSocketAnnotationMethodMessageHandler : + o.s.w.a.OpenApiWebMvcResource: + +2026-05-27T19:15:23.414+07:00 DEBUG 14960 --- [ main] .WebSocketAnnotationMethodMessageHandler : + o.s.w.u.SwaggerWelcomeWebMvc: + +2026-05-27T19:15:23.415+07:00 DEBUG 14960 --- [ main] .WebSocketAnnotationMethodMessageHandler : + o.s.w.u.SwaggerConfigResource: + +2026-05-27T19:15:23.710+07:00 INFO 14960 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '' +2026-05-27T19:15:23.724+07:00 DEBUG 14960 --- [ main] o.s.m.s.ExecutorSubscribableChannel : clientOutboundChannel added SubProtocolWebSocketHandler[StompSubProtocolHandler[v10.stomp, v11.stomp, v12.stomp]] +2026-05-27T19:15:23.725+07:00 DEBUG 14960 --- [ main] o.s.m.s.ExecutorSubscribableChannel : clientInboundChannel added WebSocketAnnotationMethodMessageHandler[prefixes=[/app/]] +2026-05-27T19:15:23.726+07:00 INFO 14960 --- [ main] o.s.m.s.b.SimpleBrokerMessageHandler : Starting... +2026-05-27T19:15:23.726+07:00 DEBUG 14960 --- [ main] o.s.m.s.ExecutorSubscribableChannel : clientInboundChannel added SimpleBrokerMessageHandler [org.springframework.messaging.simp.broker.DefaultSubscriptionRegistry@61ee9672] +2026-05-27T19:15:23.726+07:00 DEBUG 14960 --- [ main] o.s.m.s.ExecutorSubscribableChannel : brokerChannel added SimpleBrokerMessageHandler [org.springframework.messaging.simp.broker.DefaultSubscriptionRegistry@61ee9672] +2026-05-27T19:15:23.728+07:00 INFO 14960 --- [ main] o.s.m.s.b.SimpleBrokerMessageHandler : BrokerAvailabilityEvent[available=true, SimpleBrokerMessageHandler [org.springframework.messaging.simp.broker.DefaultSubscriptionRegistry@61ee9672]] +2026-05-27T19:15:23.729+07:00 INFO 14960 --- [ main] o.s.m.s.b.SimpleBrokerMessageHandler : Started. +2026-05-27T19:15:23.730+07:00 DEBUG 14960 --- [ main] o.s.m.s.ExecutorSubscribableChannel : clientInboundChannel added UserDestinationMessageHandler[DefaultUserDestinationResolver[prefix=/user/]] +2026-05-27T19:15:23.730+07:00 DEBUG 14960 --- [ main] o.s.m.s.ExecutorSubscribableChannel : brokerChannel added UserDestinationMessageHandler[DefaultUserDestinationResolver[prefix=/user/]] +2026-05-27T19:15:23.737+07:00 INFO 14960 --- [ main] com.walkguide.WalkGuideApplication : Started WalkGuideApplication in 19.764 seconds (process running for 20.253) +Hibernate: + select + count(*) + from + users u1_0 +DataSeeder: Database sudah ada data, skip seeding. +2026-05-27T19:15:38.814+07:00 INFO 14960 --- [0.0-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet' +2026-05-27T19:15:38.814+07:00 INFO 14960 --- [0.0-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet' +2026-05-27T19:15:38.815+07:00 INFO 14960 --- [0.0-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms +2026-05-27T19:16:23.704+07:00 INFO 14960 --- [MessageBroker-1] o.s.w.s.c.WebSocketMessageBrokerStats : WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 0 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(0)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], sockJsScheduler[pool size = 1, active threads = 1, queued tasks = 0, completed tasks = 0] +2026-05-27T19:18:42.664+07:00 WARN 14960 --- [0.0-8080-exec-7] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 0, SQLState: 08001 +2026-05-27T19:18:42.676+07:00 ERROR 14960 --- [0.0-8080-exec-7] o.h.engine.jdbc.spi.SqlExceptionHelper : HikariPool-1 - Connection is not available, request timed out after 10011ms. +2026-05-27T19:18:42.676+07:00 ERROR 14960 --- [0.0-8080-exec-7] o.h.engine.jdbc.spi.SqlExceptionHelper : The connection attempt failed. +Hibernate: + select + rt1_0.id, + rt1_0.created_at, + rt1_0.expires_at, + rt1_0.token, + rt1_0.user_id + from + refresh_tokens rt1_0 + where + rt1_0.user_id is null +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.email=? +Hibernate: + select + rt1_0.id, + rt1_0.created_at, + rt1_0.expires_at, + rt1_0.token, + rt1_0.user_id + from + refresh_tokens rt1_0 + where + rt1_0.user_id=? +Hibernate: + insert + into + activity_logs + (created_at, description, log_type, metadata, user_id) + values + (?, ?, ?, ?, ?) +Hibernate: + insert + into + refresh_tokens + (created_at, expires_at, token, user_id) + values + (?, ?, ?, ?) +Hibernate: + delete + from + refresh_tokens + where + id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + update + users + set + display_name=?, + email=?, + fcm_token=?, + pairing_code=?, + pairing_code_expires_at=?, + password=?, + role=?, + unique_user_id=?, + updated_at=? + where + id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + lh1_0.id, + lh1_0.accuracy, + lh1_0.created_at, + lh1_0.heading, + lh1_0.lat, + lh1_0.lng, + lh1_0.speed, + lh1_0.user_id + from + location_history lh1_0 + where + lh1_0.user_id=? + order by + lh1_0.created_at desc + fetch + first ? rows only +2026-05-27T19:28:59.450+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:28:59.456+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + al1_0.id, + al1_0.created_at, + al1_0.description, + al1_0.log_type, + al1_0.metadata, + al1_0.user_id + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? + order by + al1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(al1_0.id) + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? +Hibernate: + select + se1_0.id, + se1_0.acknowledged_at, + se1_0.created_at, + se1_0.lat, + se1_0.lng, + se1_0.status, + se1_0.trigger_type, + se1_0.user_id + from + sos_events se1_0 + where + se1_0.user_id=? + order by + se1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(gn1_0.id) + from + guardian_notifications gn1_0 + where + gn1_0.user_id=? + and not(gn1_0.is_read) +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + al1_0.id, + al1_0.created_at, + al1_0.description, + al1_0.log_type, + al1_0.metadata, + al1_0.user_id + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? + order by + al1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(al1_0.id) + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? +Hibernate: + select + se1_0.id, + se1_0.acknowledged_at, + se1_0.created_at, + se1_0.lat, + se1_0.lng, + se1_0.status, + se1_0.trigger_type, + se1_0.user_id + from + sos_events se1_0 + where + se1_0.user_id=? + order by + se1_0.created_at desc + offset + ? rows + fetch + first ? rows only +2026-05-27T19:29:04.606+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:04.607+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:29:09.779+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:09.786+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:29:14.951+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:14.951+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.email=? +Hibernate: + select + rt1_0.id, + rt1_0.created_at, + rt1_0.expires_at, + rt1_0.token, + rt1_0.user_id + from + refresh_tokens rt1_0 + where + rt1_0.user_id=? +Hibernate: + insert + into + activity_logs + (created_at, description, log_type, metadata, user_id) + values + (?, ?, ?, ?, ?) +Hibernate: + insert + into + refresh_tokens + (created_at, expires_at, token, user_id) + values + (?, ?, ?, ?) +Hibernate: + delete + from + refresh_tokens + where + id=? +2026-05-27T19:29:20.100+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:20.113+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + vcc1_0.id, + vcc1_0.command_key, + vcc1_0.enabled, + vcc1_0.guardian_id, + vcc1_0.trigger_phrase, + vcc1_0.updated_at, + vcc1_0.user_id + from + voice_command_configs vcc1_0 + where + vcc1_0.user_id=? +Hibernate: + select + hs1_0.id, + hs1_0.button_code, + hs1_0.button_name, + hs1_0.enabled, + hs1_0.guardian_id, + hs1_0.shortcut_key, + hs1_0.updated_at, + hs1_0.user_id + from + hardware_shortcuts hs1_0 + where + hs1_0.user_id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users u1_0 + on u1_0.id=pr1_0.user_id + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:29:22.109+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:22.110+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:29:27.233+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:27.234+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:29:32.402+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:32.414+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:29:37.567+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:37.579+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:29:42.732+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:42.744+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:29:47.899+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:47.911+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:29:53.062+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:53.063+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.email=? +Hibernate: + select + rt1_0.id, + rt1_0.created_at, + rt1_0.expires_at, + rt1_0.token, + rt1_0.user_id + from + refresh_tokens rt1_0 + where + rt1_0.user_id=? +Hibernate: + insert + into + activity_logs + (created_at, description, log_type, metadata, user_id) + values + (?, ?, ?, ?, ?) +Hibernate: + insert + into + refresh_tokens + (created_at, expires_at, token, user_id) + values + (?, ?, ?, ?) +Hibernate: + delete + from + refresh_tokens + where + id=? +2026-05-27T19:29:58.228+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:29:58.241+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + update + users + set + display_name=?, + email=?, + fcm_token=?, + pairing_code=?, + pairing_code_expires_at=?, + password=?, + role=?, + unique_user_id=?, + updated_at=? + where + id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + lh1_0.id, + lh1_0.accuracy, + lh1_0.created_at, + lh1_0.heading, + lh1_0.lat, + lh1_0.lng, + lh1_0.speed, + lh1_0.user_id + from + location_history lh1_0 + where + lh1_0.user_id=? + order by + lh1_0.created_at desc + fetch + first ? rows only +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + al1_0.id, + al1_0.created_at, + al1_0.description, + al1_0.log_type, + al1_0.metadata, + al1_0.user_id + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? + order by + al1_0.created_at desc + offset + ? rows + fetch + first ? rows only +2026-05-27T19:30:00.506+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:00.507+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + count(al1_0.id) + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? +Hibernate: + select + se1_0.id, + se1_0.acknowledged_at, + se1_0.created_at, + se1_0.lat, + se1_0.lng, + se1_0.status, + se1_0.trigger_type, + se1_0.user_id + from + sos_events se1_0 + where + se1_0.user_id=? + order by + se1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(gn1_0.id) + from + guardian_notifications gn1_0 + where + gn1_0.user_id=? + and not(gn1_0.is_read) +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + al1_0.id, + al1_0.created_at, + al1_0.description, + al1_0.log_type, + al1_0.metadata, + al1_0.user_id + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? + order by + al1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(al1_0.id) + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? +Hibernate: + select + se1_0.id, + se1_0.acknowledged_at, + se1_0.created_at, + se1_0.lat, + se1_0.lng, + se1_0.status, + se1_0.trigger_type, + se1_0.user_id + from + sos_events se1_0 + where + se1_0.user_id=? + order by + se1_0.created_at desc + offset + ? rows + fetch + first ? rows only +2026-05-27T19:30:03.397+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:03.408+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? +2026-05-27T19:30:05.610+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:05.622+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:30:07.814+07:00 INFO 14960 --- [0.0-8080-exec-4] c.walkguide.service.AgoraTokenService : [AGORA] Token generated untuk channel=call_2067_2068 uid=2068 expires=1779888607 +2026-05-27T19:30:07.817+07:00 INFO 14960 --- [0.0-8080-exec-4] c.walkguide.controller.CallController : [CALL] Token generated | caller=2068 receiver=2067 channel=call_2067_2068 +2026-05-27T19:30:08.564+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:08.565+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:30:10.773+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:10.785+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:30:13.728+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:13.739+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:30:15.944+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:15.956+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + se1_0.id, + se1_0.acknowledged_at, + se1_0.created_at, + se1_0.lat, + se1_0.lng, + se1_0.status, + se1_0.trigger_type, + se1_0.user_id + from + sos_events se1_0 + where + se1_0.user_id=? + order by + se1_0.created_at desc + offset + ? rows + fetch + first ? rows only +2026-05-27T19:30:18.908+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:18.919+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users u1_0 + on u1_0.id=pr1_0.user_id + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:30:21.106+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:21.117+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:30:24.059+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:24.098+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:30:26.273+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:26.274+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users u1_0 + on u1_0.id=pr1_0.user_id + where + u1_0.id=? +2026-05-27T19:30:29.237+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:29.238+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + lh1_0.id, + lh1_0.accuracy, + lh1_0.created_at, + lh1_0.heading, + lh1_0.lat, + lh1_0.lng, + lh1_0.speed, + lh1_0.user_id + from + location_history lh1_0 + where + lh1_0.user_id=? + order by + lh1_0.created_at desc + fetch + first ? rows only +Hibernate: + select + al1_0.id, + al1_0.created_at, + al1_0.description, + al1_0.log_type, + al1_0.metadata, + al1_0.user_id + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? + order by + al1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(al1_0.id) + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? +Hibernate: + select + se1_0.id, + se1_0.acknowledged_at, + se1_0.created_at, + se1_0.lat, + se1_0.lng, + se1_0.status, + se1_0.trigger_type, + se1_0.user_id + from + sos_events se1_0 + where + se1_0.user_id=? + order by + se1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(gn1_0.id) + from + guardian_notifications gn1_0 + where + gn1_0.user_id=? + and not(gn1_0.is_read) +2026-05-27T19:30:31.440+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:31.441+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + al1_0.id, + al1_0.created_at, + al1_0.description, + al1_0.log_type, + al1_0.metadata, + al1_0.user_id + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? + order by + al1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(al1_0.id) + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? +Hibernate: + select + se1_0.id, + se1_0.acknowledged_at, + se1_0.created_at, + se1_0.lat, + se1_0.lng, + se1_0.status, + se1_0.trigger_type, + se1_0.user_id + from + sos_events se1_0 + where + se1_0.user_id=? + order by + se1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:30:34.390+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:34.391+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:30:36.101+07:00 INFO 14960 --- [0.0-8080-exec-4] c.walkguide.service.AgoraTokenService : [AGORA] Token generated untuk channel=call_2067_2068 uid=2067 expires=1779888636 +2026-05-27T19:30:36.112+07:00 INFO 14960 --- [0.0-8080-exec-4] c.walkguide.controller.CallController : [CALL] Token generated | caller=2067 receiver=2068 channel=call_2067_2068 +2026-05-27T19:30:36.606+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:36.618+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:30:39.554+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:39.555+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:30:41.778+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:41.790+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users u1_0 + on u1_0.id=pr1_0.user_id + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:30:44.718+07:00 DEBUG 14960 --- [.0-8080-exec-10] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:44.724+07:00 DEBUG 14960 --- [.0-8080-exec-10] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:30:46.928+07:00 INFO 14960 --- [0.0-8080-exec-4] c.walkguide.service.AgoraTokenService : [AGORA] Token generated untuk channel=call_2067_2068 uid=2068 expires=1779888646 +2026-05-27T19:30:46.939+07:00 INFO 14960 --- [0.0-8080-exec-4] c.walkguide.controller.CallController : [CALL] Token generated | caller=2068 receiver=2067 channel=call_2067_2068 +2026-05-27T19:30:46.939+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:46.939+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:30:48.914+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2067 session=null payload={"receiverId":"2067","channelName":"call_2067_2068","callerId":"2068","type":"IN...(truncated) +2026-05-27T19:30:48.920+07:00 INFO 14960 --- [0.0-8080-exec-1] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2067 | type=INCOMING_CALL status=RINGING channel=call_2067_2068 +2026-05-27T19:30:49.898+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:49.899+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:30:52.103+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:52.115+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:30:53.589+07:00 INFO 14960 --- [0.0-8080-exec-1] com.walkguide.service.FcmService : [FCM] Sent high-priority notification successfully: projects/walkguide-549b3/messages/0:1779885052504510%084e7484084e7484 +2026-05-27T19:30:55.060+07:00 DEBUG 14960 --- [.0-8080-exec-10] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:55.062+07:00 DEBUG 14960 --- [.0-8080-exec-10] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:30:55.909+07:00 WARN 14960 --- [0.0-8080-exec-1] com.walkguide.service.FcmService : [FIRESTORE] Notification audit skipped: com.google.api.gax.rpc.PermissionDeniedException: io.grpc.StatusRuntimeException: PERMISSION_DENIED: Cloud Firestore API has not been used in project walkguide-549b3 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firestore.googleapis.com/overview?project=walkguide-549b3 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry. +2026-05-27T19:30:55.909+07:00 INFO 14960 --- [0.0-8080-exec-1] c.w.service.CallNotificationService : [CALL] Incoming call notification sent | caller=2068 receiver=2067 channel=call_2067_2068 +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:30:57.011+07:00 INFO 14960 --- [0.0-8080-exec-7] c.walkguide.service.AgoraTokenService : [AGORA] Token generated untuk channel=call_2067_2068 uid=2067 expires=1779888657 +2026-05-27T19:30:57.023+07:00 INFO 14960 --- [0.0-8080-exec-7] c.walkguide.controller.CallController : [CALL] Token generated | caller=2067 receiver=2068 channel=call_2067_2068 +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:30:57.281+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:30:57.282+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:30:58.242+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2068 session=null payload={"receiverId":"2067","receiverName":"suami kobo","channelName":"call_2067_2068",...(truncated) +2026-05-27T19:30:58.253+07:00 INFO 14960 --- [0.0-8080-exec-8] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2068 | type=CALL_ACCEPTED status=ACCEPTED channel=call_2067_2068 +2026-05-27T19:30:58.254+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2067 session=null payload={"receiverId":"2067","receiverName":"suami kobo","channelName":"call_2067_2068",...(truncated) +2026-05-27T19:30:58.254+07:00 INFO 14960 --- [0.0-8080-exec-8] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2067 | type=CALL_ACCEPTED status=ACCEPTED channel=call_2067_2068 +2026-05-27T19:30:58.254+07:00 INFO 14960 --- [0.0-8080-exec-8] c.w.service.CallNotificationService : [CALL] Call accepted | caller=2068 receiver=2067 channel=call_2067_2068 +2026-05-27T19:31:00.222+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:00.224+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:02.443+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:02.453+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:05.389+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:05.401+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:07.637+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:07.648+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:10.554+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:10.566+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:12.771+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:12.782+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:15.726+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:15.737+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:17.935+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:17.947+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:20.889+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:20.901+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:23.105+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:23.112+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:26.058+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:26.070+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:28.271+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:28.283+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:31.219+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:31.231+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:33.436+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:33.437+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:36.382+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:36.393+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:38.600+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:38.601+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:41.551+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:41.564+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:43.769+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:43.781+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:46.718+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:46.719+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:48.933+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:48.935+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:51.884+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:51.885+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:52.614+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2068 session=null payload={"otherId":"2068","receiverName":"suami kobo","endedBy":"2067","type":"CALL_ENDE...(truncated) +2026-05-27T19:31:52.626+07:00 INFO 14960 --- [0.0-8080-exec-4] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2068 | type=CALL_ENDED status=ENDED channel=call_2067_2068 +2026-05-27T19:31:52.627+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2067 session=null payload={"otherId":"2068","receiverName":"suami kobo","endedBy":"2067","type":"CALL_ENDE...(truncated) +2026-05-27T19:31:52.628+07:00 INFO 14960 --- [0.0-8080-exec-4] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2067 | type=CALL_ENDED status=ENDED channel=call_2067_2068 +2026-05-27T19:31:54.122+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:54.135+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:31:56.658+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2068 session=null payload={"otherId":"2068","receiverName":"suami kobo","endedBy":"2067","type":"CALL_ENDE...(truncated) +2026-05-27T19:31:56.670+07:00 INFO 14960 --- [0.0-8080-exec-1] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2068 | type=CALL_ENDED status=ENDED channel=call_2067_2068 +2026-05-27T19:31:56.671+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2067 session=null payload={"otherId":"2068","receiverName":"suami kobo","endedBy":"2067","type":"CALL_ENDE...(truncated) +2026-05-27T19:31:56.671+07:00 INFO 14960 --- [0.0-8080-exec-1] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2067 | type=CALL_ENDED status=ENDED channel=call_2067_2068 +2026-05-27T19:31:57.050+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:57.052+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:59.268+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:31:59.280+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:31:59.991+07:00 INFO 14960 --- [0.0-8080-exec-4] com.walkguide.service.FcmService : [FCM] Sent normal notification successfully: projects/walkguide-549b3/messages/0:1779885120460350%084e7484084e7484 +2026-05-27T19:32:00.045+07:00 WARN 14960 --- [0.0-8080-exec-4] com.walkguide.service.FcmService : [FIRESTORE] Notification audit skipped: com.google.api.gax.rpc.PermissionDeniedException: io.grpc.StatusRuntimeException: PERMISSION_DENIED: Cloud Firestore API has not been used in project walkguide-549b3 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firestore.googleapis.com/overview?project=walkguide-549b3 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry. +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:32:00.576+07:00 INFO 14960 --- [0.0-8080-exec-1] com.walkguide.service.FcmService : [FCM] Sent normal notification successfully: projects/walkguide-549b3/messages/0:1779885121120572%084e7484084e7484 +2026-05-27T19:32:00.777+07:00 WARN 14960 --- [0.0-8080-exec-1] com.walkguide.service.FcmService : [FIRESTORE] Notification audit skipped: com.google.api.gax.rpc.PermissionDeniedException: io.grpc.StatusRuntimeException: PERMISSION_DENIED: Cloud Firestore API has not been used in project walkguide-549b3 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firestore.googleapis.com/overview?project=walkguide-549b3 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry. +Hibernate: + select + vcc1_0.id, + vcc1_0.command_key, + vcc1_0.enabled, + vcc1_0.guardian_id, + vcc1_0.trigger_phrase, + vcc1_0.updated_at, + vcc1_0.user_id + from + voice_command_configs vcc1_0 + where + vcc1_0.user_id=? +Hibernate: + select + hs1_0.id, + hs1_0.button_code, + hs1_0.button_name, + hs1_0.enabled, + hs1_0.guardian_id, + hs1_0.shortcut_key, + hs1_0.updated_at, + hs1_0.user_id + from + hardware_shortcuts hs1_0 + where + hs1_0.user_id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users u1_0 + on u1_0.id=pr1_0.user_id + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:32:02.214+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:02.226+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:04.463+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:04.465+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:07.397+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:07.399+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:09.446+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2067 session=null payload={"otherId":"2067","receiverName":"suami kobo","endedBy":"2068","type":"CALL_ENDE...(truncated) +2026-05-27T19:32:09.447+07:00 INFO 14960 --- [0.0-8080-exec-4] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2067 | type=CALL_ENDED status=ENDED channel=call_2067_2068 +2026-05-27T19:32:09.447+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2068 session=null payload={"otherId":"2067","receiverName":"suami kobo","endedBy":"2068","type":"CALL_ENDE...(truncated) +2026-05-27T19:32:09.447+07:00 INFO 14960 --- [0.0-8080-exec-4] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2068 | type=CALL_ENDED status=ENDED channel=call_2067_2068 +2026-05-27T19:32:09.599+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:09.600+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:32:11.061+07:00 INFO 14960 --- [0.0-8080-exec-4] com.walkguide.service.FcmService : [FCM] Sent normal notification successfully: projects/walkguide-549b3/messages/0:1779885131535053%084e7484084e7484 +2026-05-27T19:32:11.118+07:00 WARN 14960 --- [0.0-8080-exec-4] com.walkguide.service.FcmService : [FIRESTORE] Notification audit skipped: com.google.api.gax.rpc.PermissionDeniedException: io.grpc.StatusRuntimeException: PERMISSION_DENIED: Cloud Firestore API has not been used in project walkguide-549b3 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firestore.googleapis.com/overview?project=walkguide-549b3 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry. +2026-05-27T19:32:12.550+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:12.551+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:32:14.805+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:14.817+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + lh1_0.id, + lh1_0.accuracy, + lh1_0.created_at, + lh1_0.heading, + lh1_0.lat, + lh1_0.lng, + lh1_0.speed, + lh1_0.user_id + from + location_history lh1_0 + where + lh1_0.user_id=? + order by + lh1_0.created_at desc + fetch + first ? rows only +Hibernate: + select + al1_0.id, + al1_0.created_at, + al1_0.description, + al1_0.log_type, + al1_0.metadata, + al1_0.user_id + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? + order by + al1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(al1_0.id) + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? +Hibernate: + select + se1_0.id, + se1_0.acknowledged_at, + se1_0.created_at, + se1_0.lat, + se1_0.lng, + se1_0.status, + se1_0.trigger_type, + se1_0.user_id + from + sos_events se1_0 + where + se1_0.user_id=? + order by + se1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(gn1_0.id) + from + guardian_notifications gn1_0 + where + gn1_0.user_id=? + and not(gn1_0.is_read) +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:32:17.715+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:17.728+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + al1_0.id, + al1_0.created_at, + al1_0.description, + al1_0.log_type, + al1_0.metadata, + al1_0.user_id + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? + order by + al1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(al1_0.id) + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? +Hibernate: + select + se1_0.id, + se1_0.acknowledged_at, + se1_0.created_at, + se1_0.lat, + se1_0.lng, + se1_0.status, + se1_0.trigger_type, + se1_0.user_id + from + sos_events se1_0 + where + se1_0.user_id=? + order by + se1_0.created_at desc + offset + ? rows + fetch + first ? rows only +2026-05-27T19:32:19.928+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:19.929+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:22.887+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:22.926+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:25.333+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:25.345+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:32:27.296+07:00 INFO 14960 --- [0.0-8080-exec-2] c.walkguide.service.AgoraTokenService : [AGORA] Token generated untuk channel=call_2067_2068 uid=2068 expires=1779888747 +2026-05-27T19:32:27.297+07:00 INFO 14960 --- [0.0-8080-exec-2] c.walkguide.controller.CallController : [CALL] Token generated | caller=2068 receiver=2067 channel=call_2067_2068 +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:32:28.047+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:28.048+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:28.525+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2067 session=null payload={"receiverId":"2067","channelName":"call_2067_2068","callerId":"2068","type":"IN...(truncated) +2026-05-27T19:32:28.526+07:00 INFO 14960 --- [0.0-8080-exec-7] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2067 | type=INCOMING_CALL status=RINGING channel=call_2067_2068 +2026-05-27T19:32:28.775+07:00 INFO 14960 --- [0.0-8080-exec-7] com.walkguide.service.FcmService : [FCM] Sent high-priority notification successfully: projects/walkguide-549b3/messages/0:1779885149223672%084e7484084e7484 +2026-05-27T19:32:28.822+07:00 WARN 14960 --- [0.0-8080-exec-7] com.walkguide.service.FcmService : [FIRESTORE] Notification audit skipped: com.google.api.gax.rpc.PermissionDeniedException: io.grpc.StatusRuntimeException: PERMISSION_DENIED: Cloud Firestore API has not been used in project walkguide-549b3 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firestore.googleapis.com/overview?project=walkguide-549b3 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry. +2026-05-27T19:32:28.823+07:00 INFO 14960 --- [0.0-8080-exec-7] c.w.service.CallNotificationService : [CALL] Incoming call notification sent | caller=2068 receiver=2067 channel=call_2067_2068 +2026-05-27T19:32:30.510+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:30.511+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:33.210+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:33.211+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:35.696+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:35.697+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:32:37.382+07:00 INFO 14960 --- [0.0-8080-exec-4] c.walkguide.service.AgoraTokenService : [AGORA] Token generated untuk channel=call_2067_2068 uid=2067 expires=1779888757 +2026-05-27T19:32:37.394+07:00 INFO 14960 --- [0.0-8080-exec-4] c.walkguide.controller.CallController : [CALL] Token generated | caller=2067 receiver=2068 channel=call_2067_2068 +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:32:38.370+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:38.379+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:38.861+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2068 session=null payload={"receiverId":"2067","receiverName":"suami kobo","channelName":"call_2067_2068",...(truncated) +2026-05-27T19:32:38.861+07:00 INFO 14960 --- [0.0-8080-exec-5] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2068 | type=CALL_ACCEPTED status=ACCEPTED channel=call_2067_2068 +2026-05-27T19:32:38.862+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2067 session=null payload={"receiverId":"2067","receiverName":"suami kobo","channelName":"call_2067_2068",...(truncated) +2026-05-27T19:32:38.862+07:00 INFO 14960 --- [0.0-8080-exec-5] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2067 | type=CALL_ACCEPTED status=ACCEPTED channel=call_2067_2068 +2026-05-27T19:32:38.862+07:00 INFO 14960 --- [0.0-8080-exec-5] c.w.service.CallNotificationService : [CALL] Call accepted | caller=2068 receiver=2067 channel=call_2067_2068 +2026-05-27T19:32:40.845+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:40.857+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:43.541+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:43.554+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:46.009+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:46.021+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:48.719+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:48.721+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:51.268+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:51.270+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:53.878+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:53.879+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:56.431+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:56.433+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:32:59.043+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:32:59.044+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:01.751+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:01.752+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:04.236+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:04.238+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:06.926+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:06.928+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:09.387+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:09.389+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:10.594+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2067 session=null payload={"otherId":"2067","receiverName":"suami kobo","endedBy":"2068","type":"CALL_ENDE...(truncated) +2026-05-27T19:33:10.595+07:00 INFO 14960 --- [0.0-8080-exec-7] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2067 | type=CALL_ENDED status=ENDED channel=call_2067_2068 +2026-05-27T19:33:10.596+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2068 session=null payload={"otherId":"2067","receiverName":"suami kobo","endedBy":"2068","type":"CALL_ENDE...(truncated) +2026-05-27T19:33:10.596+07:00 INFO 14960 --- [0.0-8080-exec-7] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2068 | type=CALL_ENDED status=ENDED channel=call_2067_2068 +2026-05-27T19:33:12.096+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:12.097+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + vcc1_0.id, + vcc1_0.command_key, + vcc1_0.enabled, + vcc1_0.guardian_id, + vcc1_0.trigger_phrase, + vcc1_0.updated_at, + vcc1_0.user_id + from + voice_command_configs vcc1_0 + where + vcc1_0.user_id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + hs1_0.id, + hs1_0.button_code, + hs1_0.button_name, + hs1_0.enabled, + hs1_0.guardian_id, + hs1_0.shortcut_key, + hs1_0.updated_at, + hs1_0.user_id + from + hardware_shortcuts hs1_0 + where + hs1_0.user_id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users u1_0 + on u1_0.id=pr1_0.user_id + where + u1_0.id=? +2026-05-27T19:33:13.056+07:00 INFO 14960 --- [0.0-8080-exec-7] com.walkguide.service.FcmService : [FCM] Sent normal notification successfully: projects/walkguide-549b3/messages/0:1779885193529669%084e7484084e7484 +2026-05-27T19:33:13.110+07:00 WARN 14960 --- [0.0-8080-exec-7] com.walkguide.service.FcmService : [FIRESTORE] Notification audit skipped: com.google.api.gax.rpc.PermissionDeniedException: io.grpc.StatusRuntimeException: PERMISSION_DENIED: Cloud Firestore API has not been used in project walkguide-549b3 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firestore.googleapis.com/overview?project=walkguide-549b3 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry. +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:33:14.632+07:00 DEBUG 14960 --- [.0-8080-exec-10] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:14.637+07:00 DEBUG 14960 --- [.0-8080-exec-10] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users g1_0 + on g1_0.id=pr1_0.guardian_id + where + g1_0.id=? + and pr1_0.status=? +Hibernate: + select + lh1_0.id, + lh1_0.accuracy, + lh1_0.created_at, + lh1_0.heading, + lh1_0.lat, + lh1_0.lng, + lh1_0.speed, + lh1_0.user_id + from + location_history lh1_0 + where + lh1_0.user_id=? + order by + lh1_0.created_at desc + fetch + first ? rows only +Hibernate: + select + al1_0.id, + al1_0.created_at, + al1_0.description, + al1_0.log_type, + al1_0.metadata, + al1_0.user_id + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? + order by + al1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(al1_0.id) + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? +Hibernate: + select + se1_0.id, + se1_0.acknowledged_at, + se1_0.created_at, + se1_0.lat, + se1_0.lng, + se1_0.status, + se1_0.trigger_type, + se1_0.user_id + from + sos_events se1_0 + where + se1_0.user_id=? + order by + se1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(gn1_0.id) + from + guardian_notifications gn1_0 + where + gn1_0.user_id=? + and not(gn1_0.is_read) +2026-05-27T19:33:17.259+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:17.260+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + al1_0.id, + al1_0.created_at, + al1_0.description, + al1_0.log_type, + al1_0.metadata, + al1_0.user_id + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? + order by + al1_0.created_at desc + offset + ? rows + fetch + first ? rows only +Hibernate: + select + count(al1_0.id) + from + activity_logs al1_0 + left join + users u1_0 + on u1_0.id=al1_0.user_id + where + u1_0.id=? +Hibernate: + select + se1_0.id, + se1_0.acknowledged_at, + se1_0.created_at, + se1_0.lat, + se1_0.lng, + se1_0.status, + se1_0.trigger_type, + se1_0.user_id + from + sos_events se1_0 + where + se1_0.user_id=? + order by + se1_0.created_at desc + offset + ? rows + fetch + first ? rows only +2026-05-27T19:33:19.819+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:19.822+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:22.428+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:22.431+07:00 DEBUG 14960 --- [0.0-8080-exec-7] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:24.965+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:24.966+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:27.589+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:27.594+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:30.077+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:30.081+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + pr1_0.id, + pr1_0.guardian_id, + pr1_0.invited_at, + pr1_0.responded_at, + pr1_0.status, + pr1_0.user_id + from + pairing_relations pr1_0 + left join + users u1_0 + on u1_0.id=pr1_0.user_id + where + u1_0.id=? +2026-05-27T19:33:32.755+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:32.756+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:33:35.214+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:35.217+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:35.438+07:00 INFO 14960 --- [0.0-8080-exec-3] c.walkguide.service.AgoraTokenService : [AGORA] Token generated untuk channel=call_2067_2068 uid=2067 expires=1779888815 +2026-05-27T19:33:35.441+07:00 INFO 14960 --- [0.0-8080-exec-3] c.walkguide.controller.CallController : [CALL] Token generated | caller=2067 receiver=2068 channel=call_2067_2068 +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:33:36.919+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2068 session=null payload={"receiverId":"2068","channelName":"call_2067_2068","callerId":"2067","type":"IN...(truncated) +2026-05-27T19:33:36.920+07:00 INFO 14960 --- [0.0-8080-exec-5] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2068 | type=INCOMING_CALL status=RINGING channel=call_2067_2068 +2026-05-27T19:33:37.163+07:00 INFO 14960 --- [0.0-8080-exec-5] com.walkguide.service.FcmService : [FCM] Sent high-priority notification successfully: projects/walkguide-549b3/messages/0:1779885217624637%084e7484084e7484 +2026-05-27T19:33:37.220+07:00 WARN 14960 --- [0.0-8080-exec-5] com.walkguide.service.FcmService : [FIRESTORE] Notification audit skipped: com.google.api.gax.rpc.PermissionDeniedException: io.grpc.StatusRuntimeException: PERMISSION_DENIED: Cloud Firestore API has not been used in project walkguide-549b3 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firestore.googleapis.com/overview?project=walkguide-549b3 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry. +2026-05-27T19:33:37.221+07:00 INFO 14960 --- [0.0-8080-exec-5] c.w.service.CallNotificationService : [CALL] Incoming call notification sent | caller=2067 receiver=2068 channel=call_2067_2068 +2026-05-27T19:33:37.919+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:37.920+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:40.376+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:40.379+07:00 DEBUG 14960 --- [0.0-8080-exec-2] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:33:43.099+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:43.100+07:00 DEBUG 14960 --- [0.0-8080-exec-3] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:43.804+07:00 INFO 14960 --- [.0-8080-exec-10] c.walkguide.service.AgoraTokenService : [AGORA] Token generated untuk channel=call_2067_2068 uid=2068 expires=1779888823 +2026-05-27T19:33:43.804+07:00 INFO 14960 --- [.0-8080-exec-10] c.walkguide.controller.CallController : [CALL] Token generated | caller=2068 receiver=2067 channel=call_2067_2068 +2026-05-27T19:33:45.613+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:45.615+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:48.293+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:48.295+07:00 DEBUG 14960 --- [0.0-8080-exec-8] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:49.690+07:00 WARN 14960 --- [0.0-8080-exec-9] com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@2f6912a6 (This connection has been closed.). Possibly consider using a shorter maxLifetime value. +2026-05-27T19:33:50.729+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:50.731+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +Hibernate: + select + u1_0.id, + u1_0.created_at, + u1_0.display_name, + u1_0.email, + u1_0.fcm_token, + u1_0.pairing_code, + u1_0.pairing_code_expires_at, + u1_0.password, + u1_0.role, + u1_0.unique_user_id, + u1_0.updated_at + from + users u1_0 + where + u1_0.id=? +2026-05-27T19:33:53.427+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:53.429+07:00 DEBUG 14960 --- [0.0-8080-exec-6] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:53.890+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2067 session=null payload={"receiverId":"2068","receiverName":"GuardianRobert","channelName":"call_2067_20...(truncated) +2026-05-27T19:33:53.890+07:00 INFO 14960 --- [0.0-8080-exec-9] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2067 | type=CALL_ACCEPTED status=ACCEPTED channel=call_2067_2068 +2026-05-27T19:33:53.891+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.m.s.b.SimpleBrokerMessageHandler : Processing MESSAGE destination=/queue/call/2068 session=null payload={"receiverId":"2068","receiverName":"GuardianRobert","channelName":"call_2067_20...(truncated) +2026-05-27T19:33:53.892+07:00 INFO 14960 --- [0.0-8080-exec-9] c.w.websocket.LocationBroadcaster : [WS] Call broadcast -> /queue/call/2068 | type=CALL_ACCEPTED status=ACCEPTED channel=call_2067_2068 +2026-05-27T19:33:53.893+07:00 INFO 14960 --- [0.0-8080-exec-9] c.w.service.CallNotificationService : [CALL] Call accepted | caller=2067 receiver=2068 channel=call_2067_2068 +2026-05-27T19:33:55.880+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:55.880+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:33:58.584+07:00 DEBUG 14960 --- [.0-8080-exec-10] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:33:58.585+07:00 DEBUG 14960 --- [.0-8080-exec-10] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:34:01.039+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:34:01.042+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:34:03.749+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:34:03.751+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:34:06.209+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:34:06.210+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:34:08.918+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:34:08.919+07:00 DEBUG 14960 --- [0.0-8080-exec-9] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:34:11.369+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:34:11.371+07:00 DEBUG 14960 --- [0.0-8080-exec-1] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:34:14.082+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:34:14.083+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:34:16.535+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:34:16.535+07:00 DEBUG 14960 --- [0.0-8080-exec-4] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws +2026-05-27T19:34:19.253+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.s.WebSocketHandlerMapping : Mapped to org.springframework.web.socket.sockjs.support.SockJsHttpRequestHandler@268eefe7 +2026-05-27T19:34:19.258+07:00 DEBUG 14960 --- [0.0-8080-exec-5] o.s.w.s.s.t.h.DefaultSockJsService : Processing transport request: GET http://192.168.1.68:8080/ws diff --git a/walkguide-backend/demo/pom.xml b/walkguide-backend/demo/pom.xml index 624f7b5..1e0aeb2 100644 --- a/walkguide-backend/demo/pom.xml +++ b/walkguide-backend/demo/pom.xml @@ -104,6 +104,13 @@ + + + com.google.firebase + firebase-admin + 9.3.0 + + org.springframework.boot diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/config/FirebaseConfig.java b/walkguide-backend/demo/src/main/java/com/walkguide/config/FirebaseConfig.java new file mode 100644 index 0000000..c4af300 --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/config/FirebaseConfig.java @@ -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()); + } + } +} diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java b/walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java index 36d35b5..fbeab8d 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java @@ -14,9 +14,12 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @@ -36,35 +39,76 @@ public class CallController { @Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora") public ResponseEntity> generateToken( @Valid @RequestBody CallTokenRequest req) { - Long callerId = SecurityHelper.getCurrentUserId(); AgoraTokenResponse response = agoraTokenService.generateToken(callerId, req.getReceiverId()); - log.info("[CALL] Token generated | caller={} receiver={} channel={}", callerId, req.getReceiverId(), response.getChannelName()); - return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate")); } @PostMapping("/notify") @Operation(summary = "Notify receiver of incoming call") - public ResponseEntity> notifyCall( - @Valid @RequestBody CallNotifyRequest req) { - + public ResponseEntity> notifyCall(@Valid @RequestBody CallNotifyRequest req) { Long callerId = SecurityHelper.getCurrentUserId(); String message = callNotificationService.notifyIncomingCall(callerId, req); return ResponseEntity.ok(ApiResponse.ok(null, message)); } + @PostMapping("/accept") + @Operation(summary = "Receiver accepts incoming call") + public ResponseEntity>> acceptCall(@RequestBody Map 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>> 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> 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>> 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> 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>> callState(@RequestParam String channelName) { + return ResponseEntity.ok(ApiResponse.ok(callNotificationService.getCallState(channelName), "Call state")); + } + @PostMapping("/end") @Operation(summary = "Notify end of call") - public ResponseEntity> endCall( - @RequestBody Map body) { - + public ResponseEntity> endCall(@RequestBody Map body) { Long callerId = SecurityHelper.getCurrentUserId(); - Long otherId = body.get("otherId"); - callNotificationService.notifyCallEnded(callerId, otherId); - + Long otherId = Long.parseLong(body.get("otherId")); + String channelName = body.get("channelName"); + callNotificationService.notifyCallEnded(callerId, otherId, channelName); return ResponseEntity.ok(ApiResponse.ok(null, "Call ended")); } -} +} \ No newline at end of file diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/exception/GlobalExceptionHandler.java b/walkguide-backend/demo/src/main/java/com/walkguide/exception/GlobalExceptionHandler.java index 5d307bf..11740ba 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/exception/GlobalExceptionHandler.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.walkguide.exception; import com.walkguide.dto.ApiResponse; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -29,6 +30,13 @@ public class GlobalExceptionHandler { .body(ApiResponse.error("VALIDATION_ERROR", msg)); } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity> handleDataIntegrity(DataIntegrityViolationException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResponse.error("DATA_CONFLICT", + "Data pairing lama masih bentrok. Refresh status atau unpair dulu, lalu coba lagi.")); + } @ExceptionHandler(RuntimeException.class) public ResponseEntity> handleRuntime(RuntimeException ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/CallNotificationService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/CallNotificationService.java index fc45d02..2bb3924 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/CallNotificationService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/CallNotificationService.java @@ -4,11 +4,14 @@ import com.walkguide.dto.request.CallNotifyRequest; import com.walkguide.entity.User; import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.repository.UserRepository; +import com.walkguide.websocket.LocationBroadcaster; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; @Service @RequiredArgsConstructor @@ -17,29 +20,38 @@ public class CallNotificationService { private final FcmService fcmService; private final UserRepository userRepository; + private final LocationBroadcaster locationBroadcaster; + private final Map> pendingCalls = new ConcurrentHashMap<>(); + private final Map> acceptedCalls = new ConcurrentHashMap<>(); + private final Map> callStates = new ConcurrentHashMap<>(); public String notifyIncomingCall(Long callerId, CallNotifyRequest req) { User caller = userRepository.findById(callerId) .orElseThrow(() -> new ResourceNotFoundException("Caller not found")); - User receiver = userRepository.findById(req.getReceiverId()) .orElseThrow(() -> new ResourceNotFoundException("Receiver not found")); + String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail(); + Map 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()) { 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 payload = Map.of( - "type", "INCOMING_CALL", - "callerId", String.valueOf(callerId), - "callerName", callerName, - "channelName", req.getChannelName(), - "agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "", - "receiverUid", String.valueOf(req.getReceiverUid()) - ); - fcmService.sendHighPriority( receiver.getFcmToken(), "Panggilan Masuk", @@ -52,22 +64,111 @@ public class CallNotificationService { return "Notifikasi panggilan berhasil dikirim"; } + public Map 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 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 getPendingCall(Long receiverId) { + return pendingCalls.get(receiverId); + } + + public void clearPendingCall(Long receiverId) { + pendingCalls.remove(receiverId); + } + + public Map getAcceptedCall(Long callerId) { + return acceptedCalls.get(callerId); + } + + public void clearAcceptedCall(Long callerId) { + acceptedCalls.remove(callerId); + } + + public Map getCallState(String channelName) { + if (channelName == null || channelName.isBlank()) { + return new HashMap<>(); + } + return callStates.getOrDefault(channelName, new HashMap<>()); + } + public void notifyCallEnded(Long callerId, Long otherId) { + notifyCallEnded(callerId, otherId, null); + } + + public void notifyCallEnded(Long callerId, Long otherId, String channelName) { if (otherId == null) { return; } + clearPendingCall(otherId); + clearPendingCall(callerId); + clearAcceptedCall(callerId); + clearAcceptedCall(otherId); + + String resolvedChannel = channelName; + if (resolvedChannel == null || resolvedChannel.isBlank()) { + resolvedChannel = findActiveChannel(callerId, otherId); + } + Map payload = new HashMap<>(getCallState(resolvedChannel)); + payload.put("type", "CALL_ENDED"); + payload.put("status", "ENDED"); + payload.put("callerId", String.valueOf(callerId)); + payload.put("otherId", String.valueOf(otherId)); + payload.put("channelName", resolvedChannel != null ? resolvedChannel : ""); + payload.put("endedBy", String.valueOf(callerId)); + payload.put("endedAt", String.valueOf(System.currentTimeMillis())); + if (resolvedChannel != null && !resolvedChannel.isBlank()) { + callStates.put(resolvedChannel, payload); + } + + locationBroadcaster.broadcastCall(otherId, payload); + locationBroadcaster.broadcastCall(callerId, payload); + userRepository.findById(otherId).ifPresent(other -> { if (other.getFcmToken() == null || other.getFcmToken().isBlank()) { return; } - fcmService.sendToToken( other.getFcmToken(), "Panggilan Berakhir", "Panggilan telah berakhir", - Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId)) + payload ); }); } -} + + private String findActiveChannel(Long userA, Long userB) { + String a = String.valueOf(userA); + String b = String.valueOf(userB); + return callStates.entrySet().stream() + .filter(e -> a.equals(e.getValue().get("callerId")) && b.equals(e.getValue().get("receiverId")) + || b.equals(e.getValue().get("callerId")) && a.equals(e.getValue().get("receiverId"))) + .map(Map.Entry::getKey) + .findFirst() + .orElse(null); + } +} \ No newline at end of file diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/FcmService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/FcmService.java index 466a707..b39bd6e 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/FcmService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/FcmService.java @@ -1,50 +1,130 @@ package com.walkguide.service; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.google.cloud.Timestamp; +import com.google.cloud.firestore.Firestore; +import com.google.firebase.FirebaseApp; +import com.google.firebase.cloud.FirestoreClient; +import com.google.firebase.messaging.AndroidConfig; +import com.google.firebase.messaging.AndroidNotification; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.time.Instant; +import java.util.HashMap; import java.util.Map; /** - * FCM Service untuk push notification. - * Saat ini dalam mode LOG-ONLY agar tidak butuh Firebase credentials dulu. - * Untuk enable FCM nyata: uncomment bagian Firebase dan tambah dependency Firebase Admin SDK. + * FCM Service untuk push notification dan audit notifikasi ke Firestore. + * Jika Firebase credential belum tersedia, service tetap aman berjalan dalam mode log-only. */ @Service -@RequiredArgsConstructor -@Slf4j public class FcmService { - public void sendToToken(String fcmToken, String title, String body, Map data) { - if (fcmToken == null || fcmToken.isBlank()) { - log.warn("[FCM] Token kosong, skip notifikasi: {} - {}", title, body); - return; - } - // LOG ONLY untuk sekarang - log.info("[FCM] TO={} | TITLE={} | BODY={} | DATA={}", fcmToken, title, body, data); + private static final Logger log = LoggerFactory.getLogger(FcmService.class); - // TODO: uncomment ini setelah tambah Firebase Admin SDK ke pom.xml - // dan taruh google-services-admin.json di src/main/resources/firebase/ - // - // try { - // Message message = Message.builder() - // .setToken(fcmToken) - // .setNotification(Notification.builder().setTitle(title).setBody(body).build()) - // .putAllData(data != null ? data : Map.of()) - // .setAndroidConfig(AndroidConfig.builder() - // .setPriority(AndroidConfig.Priority.HIGH) - // .build()) - // .build(); - // String response = FirebaseMessaging.getInstance().send(message); - // log.info("[FCM] Sent successfully: {}", response); - // } catch (FirebaseMessagingException e) { - // log.error("[FCM] Failed to send: {}", e.getMessage()); - // } + public void sendToToken(String fcmToken, String title, String body, Map data) { + sendInternal(fcmToken, title, body, data, false); } public void sendHighPriority(String fcmToken, String title, String body, Map data) { - // SOS dan incoming call pakai ini - sama untuk sekarang - sendToToken(fcmToken, title, body, data); + sendInternal(fcmToken, title, body, data, true); + } + + @Value("${firebase.notifications-collection:notifications}") + private String notificationsCollection; + + private void sendInternal(String fcmToken, String title, String body, Map data, boolean highPriority) { + Map 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 data, + boolean highPriority, String status, String messageId) { + if (FirebaseApp.getApps().isEmpty()) { + return; + } + + try { + Firestore firestore = FirestoreClient.getFirestore(); + Map 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); } } + diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/PairingService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/PairingService.java index 69f0445..3a9f8e1 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/PairingService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/PairingService.java @@ -7,7 +7,6 @@ import com.walkguide.enums.*; import com.walkguide.exception.PairingException; import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.repository.*; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,7 +17,6 @@ import java.util.List; import java.util.Map; @Service -@RequiredArgsConstructor public class PairingService { private final PairingRelationRepository pairingRelationRepository; @@ -34,6 +32,22 @@ public class PairingService { private static final int PAIRING_CODE_TTL_MINUTES = 15; private static final SecureRandom RANDOM = new SecureRandom(); + public PairingService(PairingRelationRepository pairingRelationRepository, + UserRepository userRepository, + VoiceCommandConfigRepository voiceCommandConfigRepository, + HardwareShortcutRepository hardwareShortcutRepository, + AiConfigRepository aiConfigRepository, + ActivityLogService activityLogService, + FcmService fcmService) { + this.pairingRelationRepository = pairingRelationRepository; + this.userRepository = userRepository; + this.voiceCommandConfigRepository = voiceCommandConfigRepository; + this.hardwareShortcutRepository = hardwareShortcutRepository; + this.aiConfigRepository = aiConfigRepository; + this.activityLogService = activityLogService; + this.fcmService = fcmService; + } + @Transactional public PairingCodeResponse getOrCreatePairingCode(Long userId) { User user = userRepository.findById(userId) @@ -69,7 +83,6 @@ public class PairingService { @Transactional public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) { - // Guardian tidak boleh punya pairing ACTIVE atau PENDING if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) { throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru."); } @@ -88,6 +101,52 @@ public class PairingService { throw new PairingException("User ini sudah dipair dengan Guardian lain."); } + var existingGuardianPairing = pairingRelationRepository.findByGuardian_Id(guardianId); + if (existingGuardianPairing.isPresent()) { + PairingRelation existing = existingGuardianPairing.get(); + if (existing.getStatus() == PairingStatus.ACTIVE) { + if (existing.getUser().getId().equals(user.getId())) { + return buildStatus(existing, guardian, existing.getUser(), "GUARDIAN"); + } + throw new PairingException( + "Guardian sudah pairing aktif dengan User lain. Unpair dulu sebelum invite User baru."); + } + if (existing.getStatus() == PairingStatus.PENDING) { + if (existing.getUser().getId().equals(user.getId())) { + sendPairingInviteNotification(existing, guardian, user); + return buildStatus(existing, guardian, user, "GUARDIAN"); + } + throw new PairingException( + "Guardian masih punya undangan pairing yang menunggu respons User."); + } + } + + var existingUserPairing = pairingRelationRepository.findByUser_Id(user.getId()); + if (existingUserPairing.isPresent()) { + PairingRelation existing = existingUserPairing.get(); + if (existing.getStatus() == PairingStatus.ACTIVE) { + throw new PairingException("User ini sudah dipair dengan Guardian lain."); + } + if (existing.getStatus() == PairingStatus.PENDING) { + if (existing.getGuardian().getId().equals(guardianId)) { + sendPairingInviteNotification(existing, guardian, user); + return buildStatus(existing, guardian, user, "GUARDIAN"); + } + throw new PairingException("User ini masih punya undangan pairing dari Guardian lain."); + } + } + + if (existingGuardianPairing.isPresent()) { + pairingRelationRepository.delete(existingGuardianPairing.get()); + pairingRelationRepository.flush(); + } + if (existingUserPairing.isPresent() + && (existingGuardianPairing.isEmpty() + || !existingUserPairing.get().getId().equals(existingGuardianPairing.get().getId()))) { + pairingRelationRepository.delete(existingUserPairing.get()); + pairingRelationRepository.flush(); + } + PairingRelation pairing = PairingRelation.builder() .guardian(guardian) .user(user) @@ -99,11 +158,7 @@ public class PairingService { user.setPairingCodeExpiresAt(null); userRepository.save(user); - // Kirim FCM ke user - fcmService.sendToToken(user.getFcmToken(), - "Pairing Request", - "Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung", - Map.of("type", "PAIRING_INVITE", "guardianName", guardian.getDisplayName())); + sendPairingInviteNotification(pairing, guardian, user); activityLogService.createLog(guardian, ActivityLogType.PAIRING_INVITE_SENT, "Guardian mengirim invite ke " + user.getDisplayName(), null); @@ -195,6 +250,13 @@ public class PairingService { // ========== PRIVATE ========== private void seedDefaults(Long guardianId, Long userId) { + voiceCommandConfigRepository.deleteByUserId(userId); + hardwareShortcutRepository.deleteByUserId(userId); + aiConfigRepository.findByUserId(userId).ifPresent(aiConfigRepository::delete); + voiceCommandConfigRepository.flush(); + hardwareShortcutRepository.flush(); + aiConfigRepository.flush(); + // Voice commands default List defaults = List.of( vc(guardianId, userId, VoiceCommandKey.OPEN_WALKGUIDE, "Open Walkguide"), @@ -261,6 +323,15 @@ public class PairingService { return user; } + private void sendPairingInviteNotification(PairingRelation pairing, User guardian, User user) { + fcmService.sendToToken(user.getFcmToken(), + "Pairing Request", + "Guardian " + guardian.getDisplayName() + " mengundang kamu untuk terhubung", + Map.of( + "type", "PAIRING_INVITE", + "pairingId", pairing.getId().toString(), + "guardianName", guardian.getDisplayName())); + } private void assignNewPairingCode(User user, LocalDateTime now) { String candidate; do { @@ -307,3 +378,4 @@ public class PairingService { .build(); } } + diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java index bd68ab0..6210717 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java @@ -7,6 +7,7 @@ import com.walkguide.entity.User; import com.walkguide.enums.ActivityLogType; import com.walkguide.enums.PairingStatus; import com.walkguide.enums.SosStatus; +import com.walkguide.exception.PairingException; import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.repository.*; import com.walkguide.websocket.LocationBroadcaster; @@ -36,6 +37,14 @@ public class SosService { @Transactional public SosEventResponse triggerSos(Long userId, SosRequest req) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan")); + + var activePairing = pairingRelationRepository + .findByUser_IdAndStatus(userId, PairingStatus.ACTIVE) + .orElseThrow(() -> new PairingException( + "SOS hanya bisa dikirim setelah User terhubung dengan Guardian aktif.")); + SosEvent sos = SosEvent.builder() .userId(userId) .triggerType(req.getTriggerType() != null ? req.getTriggerType() : "MANUAL") @@ -46,18 +55,13 @@ public class SosService { sos = sosEventRepository.save(sos); final SosEvent savedSos = sos; - User user = userRepository.findById(userId) - .orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan")); - activityLogService.createLog(user, ActivityLogType.SOS_TRIGGERED, "SOS dikirim via " + sos.getTriggerType(), null); SosEventResponse sosResponse = toResponse(savedSos); // Kirim ke Guardian via FCM (background) + WebSocket (foreground) - pairingRelationRepository.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE) - .ifPresent(pairing -> { - User guardian = pairing.getGuardian(); + User guardian = activePairing.getGuardian(); String guardianFcm = guardian.getFcmToken(); String locStr = req.getLat() != null ? String.format("Lat:%.4f,Lng:%.4f", req.getLat(), req.getLng()) @@ -78,7 +82,6 @@ public class SosService { log.info("[SOS] Alert sent to Guardian={} for User={} | trigger={}", guardian.getId(), userId, savedSos.getTriggerType()); - }); return sosResponse; } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/websocket/LocationBroadcaster.java b/walkguide-backend/demo/src/main/java/com/walkguide/websocket/LocationBroadcaster.java index a73d88d..3b4f83f 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/websocket/LocationBroadcaster.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/websocket/LocationBroadcaster.java @@ -3,68 +3,49 @@ package com.walkguide.websocket; import com.walkguide.dto.response.LocationResponse; import com.walkguide.dto.response.NotificationResponse; import com.walkguide.dto.response.SosEventResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; -/** - * Service untuk broadcast pesan real-time via WebSocket (STOMP). - * - * Dipakai oleh: - * - LocationService → broadcast GPS ke Guardian - * - SosService → broadcast SOS ke Guardian - * - NotificationService→ broadcast notif ke User - * - * PATTERN: Observer — Guardian/User subscribe ke topic, - * LocationBroadcaster push data saat ada update. - */ +import java.util.Map; + @Service -@RequiredArgsConstructor -@Slf4j public class LocationBroadcaster { + private static final Logger log = LoggerFactory.getLogger(LocationBroadcaster.class); + private final SimpMessagingTemplate messagingTemplate; - /** - * Broadcast lokasi GPS user ke Guardian yang subscribe. - * Guardian Flutter subscribe ke: /topic/location/{userId} - * - * @param userId ID dari ROLE_USER (bukan guardian) - * @param location Response lokasi terbaru - */ + public LocationBroadcaster(SimpMessagingTemplate messagingTemplate) { + this.messagingTemplate = messagingTemplate; + } + public void broadcastLocation(Long userId, LocationResponse location) { String destination = "/topic/location/" + userId; messagingTemplate.convertAndSend(destination, location); - log.debug("[WS] Location broadcast → {} | lat={} lng={}", + log.debug("[WS] Location broadcast -> {} | lat={} lng={}", destination, location.getLat(), location.getLng()); } - /** - * Broadcast SOS event ke Guardian secara real-time. - * Guardian Flutter subscribe ke: /queue/sos/{guardianId} - * - * @param guardianId ID dari ROLE_GUARDIAN - * @param sos SOS event yang baru di-trigger - */ public void broadcastSos(Long guardianId, SosEventResponse sos) { String destination = "/queue/sos/" + guardianId; messagingTemplate.convertAndSend(destination, sos); - log.info("[WS] SOS broadcast → {} | userId={} status={}", + log.info("[WS] SOS broadcast -> {} | userId={} status={}", destination, sos.getUserId(), sos.getStatus()); } - /** - * Broadcast notifikasi dari Guardian ke User secara real-time. - * User Flutter subscribe ke: /queue/notif/{userId} - * - * @param userId ID dari ROLE_USER yang menerima notif - * @param notification Notifikasi yang baru dikirim Guardian - */ public void broadcastNotification(Long userId, NotificationResponse notification) { String destination = "/queue/notif/" + userId; messagingTemplate.convertAndSend(destination, notification); - log.debug("[WS] Notification broadcast → {} | type={}", + log.debug("[WS] Notification broadcast -> {} | type={}", destination, notification.getNotifType()); } + + public void broadcastCall(Long receiverId, Map 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")); + } } diff --git a/walkguide-backend/demo/src/main/resources/application-dev.yml b/walkguide-backend/demo/src/main/resources/application-dev.yml index 41a46e8..58e9101 100644 --- a/walkguide-backend/demo/src/main/resources/application-dev.yml +++ b/walkguide-backend/demo/src/main/resources/application-dev.yml @@ -8,7 +8,16 @@ spring: datasource: url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_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: show-sql: true @@ -21,8 +30,8 @@ jwt: expiration: ${JWT_EXPIRATION:86400000} agora: - app-id: ${AGORA_APP_ID:} - app-certificate: ${AGORA_APP_CERTIFICATE:} + app-id: ${AGORA_APP_ID:e36c2b6592e34cfda1f6ea6432a5e68d} + app-certificate: ${AGORA_APP_CERTIFICATE:70a4288475734a8c92ff8686c66cbc77} logging: level: diff --git a/walkguide-backend/demo/src/main/resources/application.properties b/walkguide-backend/demo/src/main/resources/application.properties index c5cea7b..3fc8d3b 100644 --- a/walkguide-backend/demo/src/main/resources/application.properties +++ b/walkguide-backend/demo/src/main/resources/application.properties @@ -1,11 +1,19 @@ # ===== SERVER ===== +spring.config.import=optional:file:./secrets.properties server.port=${SERVER_PORT:8080} +server.address=${SERVER_ADDRESS:0.0.0.0} # ===== POSTGRESQL CONNECTION ===== spring.datasource.url=${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001} spring.datasource.username=${DB_USERNAME:5803024001} spring.datasource.password=${DB_PASSWORD:pw5803024001} spring.datasource.driver-class-name=org.postgresql.Driver +# ===== HIKARI POOL (keep DB classroom slots low) ===== +spring.datasource.hikari.maximum-pool-size=${DB_POOL_MAX:1} +spring.datasource.hikari.minimum-idle=${DB_POOL_MIN_IDLE:0} +spring.datasource.hikari.connection-timeout=${DB_CONNECTION_TIMEOUT:10000} +spring.datasource.hikari.idle-timeout=${DB_IDLE_TIMEOUT:30000} +spring.datasource.hikari.max-lifetime=${DB_MAX_LIFETIME:120000} # ===== JPA / HIBERNATE ===== spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect @@ -27,9 +35,13 @@ springdoc.swagger-ui.path=/swagger-ui.html springdoc.api-docs.path=/v3/api-docs # ===== AGORA RTC ===== -agora.app-id=${AGORA_APP_ID:} +agora.app-id=${AGORA_APP_ID:e36c2b6592e34cfda1f6ea6432a5e68d} agora.app-certificate=${AGORA_APP_CERTIFICATE:} +# ===== FIREBASE ===== +firebase.credentials-path=${FIREBASE_CREDENTIALS_PATH:classpath:firebase/google-services-admin.json} +firebase.notifications-collection=${FIREBASE_NOTIFICATIONS_COLLECTION:notifications} + # ===== WEBSOCKET ===== # WebSocket auto-dikonfigurasi oleh WebSocketConfig.java diff --git a/walkguide-backend/demo/src/test/java/com/walkguide/service/SosServiceTest.java b/walkguide-backend/demo/src/test/java/com/walkguide/service/SosServiceTest.java index 7bf6329..b33cb55 100644 --- a/walkguide-backend/demo/src/test/java/com/walkguide/service/SosServiceTest.java +++ b/walkguide-backend/demo/src/test/java/com/walkguide/service/SosServiceTest.java @@ -4,10 +4,11 @@ import com.walkguide.dto.request.SosRequest; import com.walkguide.dto.response.SosEventResponse; import com.walkguide.entity.PairingRelation; import com.walkguide.entity.SosEvent; -import com.walkguide.entity.User; -import com.walkguide.enums.PairingStatus; -import com.walkguide.enums.SosStatus; -import com.walkguide.exception.ResourceNotFoundException; +import com.walkguide.entity.User; +import com.walkguide.enums.PairingStatus; +import com.walkguide.enums.SosStatus; +import com.walkguide.exception.PairingException; +import com.walkguide.exception.ResourceNotFoundException; import com.walkguide.repository.*; import com.walkguide.websocket.LocationBroadcaster; import org.junit.jupiter.api.BeforeEach; @@ -79,10 +80,10 @@ class SosServiceTest { req.setLat(-7.257); req.setLng(112.752); - when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos); - when(userRepository.findById(2L)).thenReturn(Optional.of(user)); - when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) - .thenReturn(Optional.empty()); // tidak ada guardian → skip FCM + when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); doNothing().when(activityLogService).createLog(any(), any(), any(), any()); SosEventResponse result = sosService.triggerSos(2L, req); @@ -103,10 +104,10 @@ class SosServiceTest { req.setLat(-7.257); req.setLng(112.752); - when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos); - when(userRepository.findById(2L)).thenReturn(Optional.of(user)); - when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) - .thenReturn(Optional.empty()); + when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) + .thenReturn(Optional.of(activePairing)); doNothing().when(activityLogService).createLog(any(), any(), any(), any()); ArgumentCaptor captor = ArgumentCaptor.forClass(SosEvent.class); @@ -147,12 +148,27 @@ class SosServiceTest { SosRequest req = new SosRequest(); req.setTriggerType("MANUAL"); - when(sosEventRepository.save(any(SosEvent.class))).thenReturn(savedSos); - when(userRepository.findById(99L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> sosService.triggerSos(99L, req)) - .isInstanceOf(ResourceNotFoundException.class); - } + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> sosService.triggerSos(99L, req)) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + @DisplayName("triggerSos - tanpa pairing aktif: harus throw PairingException dan tidak simpan SOS") + void triggerSos_unpaired_shouldThrowPairingException() { + SosRequest req = new SosRequest(); + req.setTriggerType("MANUAL"); + + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> sosService.triggerSos(2L, req)) + .isInstanceOf(PairingException.class) + .hasMessageContaining("Guardian aktif"); + verify(sosEventRepository, never()).save(any(SosEvent.class)); + } // ===== acknowledgeSos TESTS ===== diff --git a/walkguide-mobile/walkguide_app/android/app/build.gradle.kts b/walkguide-mobile/walkguide_app/android/app/build.gradle.kts index 24d296b..cfd2b78 100644 --- a/walkguide-mobile/walkguide_app/android/app/build.gradle.kts +++ b/walkguide-mobile/walkguide_app/android/app/build.gradle.kts @@ -5,6 +5,10 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +if (file("google-services.json").exists()) { + apply(plugin = "com.google.gms.google-services") +} + android { namespace = "com.example.walkguide_app" compileSdk = flutter.compileSdkVersion diff --git a/walkguide-mobile/walkguide_app/android/gradle.properties b/walkguide-mobile/walkguide_app/android/gradle.properties index 7ed25f5..d80fffc 100644 --- a/walkguide-mobile/walkguide_app/android/gradle.properties +++ b/walkguide-mobile/walkguide_app/android/gradle.properties @@ -1,4 +1,7 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +org.gradle.workers.max=2 +org.gradle.parallel=false +org.gradle.daemon=false android.useAndroidX=true android.enableJetifier=true -kotlin.incremental=false +kotlin.incremental=false \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/android/settings.gradle.kts b/walkguide-mobile/walkguide_app/android/settings.gradle.kts index fb605bc..a907d73 100644 --- a/walkguide-mobile/walkguide_app/android/settings.gradle.kts +++ b/walkguide-mobile/walkguide_app/android/settings.gradle.kts @@ -21,6 +21,7 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.google.gms.google-services") version "4.4.2" apply false } include(":app") diff --git a/walkguide-mobile/walkguide_app/lib/app/app.dart b/walkguide-mobile/walkguide_app/lib/app/app.dart index ebf00e5..b72fbdd 100644 --- a/walkguide-mobile/walkguide_app/lib/app/app.dart +++ b/walkguide-mobile/walkguide_app/lib/app/app.dart @@ -4,13 +4,14 @@ import 'package:google_fonts/google_fonts.dart'; import 'app_cubit.dart'; import 'router.dart'; +import '../core/theme/app_colors.dart'; class WalkGuideApp extends StatelessWidget { const WalkGuideApp({super.key}); @override Widget build(BuildContext context) { - const seed = Color(0xFF1A56DB); + const seed = AppColors.primary; return BlocProvider( create: (_) => AppCubit(), @@ -23,9 +24,15 @@ class WalkGuideApp extends StatelessWidget { colorScheme: ColorScheme.fromSeed( seedColor: seed, 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( builders: { TargetPlatform.android: ZoomPageTransitionsBuilder(), @@ -35,16 +42,41 @@ class WalkGuideApp extends StatelessWidget { ), appBarTheme: const AppBarTheme( centerTitle: false, - backgroundColor: Color(0xFFF4F7FB), - foregroundColor: Color(0xFF0F172A), + backgroundColor: AppColors.surface, + foregroundColor: AppColors.text, elevation: 0, 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( elevation: 0, height: 76, - backgroundColor: Colors.white.withValues(alpha: 0.96), - indicatorColor: const Color(0xFFE0E7FF), + backgroundColor: Colors.white, + indicatorColor: const Color(0xFFDDEAFE), + surfaceTintColor: Colors.transparent, labelTextStyle: WidgetStateProperty.resolveWith( (states) => TextStyle( fontSize: 12, @@ -61,7 +93,7 @@ class WalkGuideApp extends StatelessWidget { minimumSize: const Size(0, 50), textStyle: const TextStyle(fontWeight: FontWeight.w800), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(10), ), ), ), @@ -70,27 +102,38 @@ class WalkGuideApp extends StatelessWidget { minimumSize: const Size(0, 50), foregroundColor: seed, textStyle: const TextStyle(fontWeight: FontWeight.w800), - side: const BorderSide(color: Color(0xFFCBD5E1)), + side: const BorderSide(color: AppColors.border), 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( filled: true, - fillColor: const Color(0xFFF8FAFC), + fillColor: Colors.white, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.border), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: AppColors.border), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: seed, width: 1.5), ), ), diff --git a/walkguide-mobile/walkguide_app/lib/app/injection_container.dart b/walkguide-mobile/walkguide_app/lib/app/injection_container.dart index d51cd1b..8550976 100644 --- a/walkguide-mobile/walkguide_app/lib/app/injection_container.dart +++ b/walkguide-mobile/walkguide_app/lib/app/injection_container.dart @@ -10,6 +10,7 @@ import '../core/services/haptic_service.dart'; import '../core/services/call_service.dart'; import '../core/services/fcm_service.dart'; import '../core/services/hardware_shortcut_listener.dart'; +import '../core/services/incoming_call_polling_service.dart'; import '../core/services/location_reporter_service.dart'; import '../core/services/offline_queue_service.dart'; import '../core/services/stt_service.dart'; @@ -39,17 +40,24 @@ Future initDependencies() async { sl.registerLazySingleton(() => SttService()); sl.registerLazySingleton(() => HapticService()); sl.registerLazySingleton( - () => TtsWithHapticObstacleAlertStrategy(sl(), sl()), + () => TtsWithHapticObstacleAlertStrategy( + sl(), sl()), ); sl.registerLazySingleton(() => ObstacleAnalyzer()); - sl.registerLazySingleton(() => YoloDetector(sl())); + sl.registerLazySingleton( + () => YoloDetector(sl())); sl.registerLazySingleton( () => OfflineQueueService(sl()), ); sl.registerLazySingleton(() => FcmService(sl())); - sl.registerLazySingleton(() => WebSocketService(sl())); - sl.registerLazySingleton(() => LocationReporterService(sl(), sl())); + sl.registerLazySingleton( + () => WebSocketService(sl())); + sl.registerLazySingleton(() => + LocationReporterService(sl(), sl())); sl.registerLazySingleton(() => CallService(sl())); + sl.registerLazySingleton( + () => IncomingCallPollingService(sl()), + ); sl.registerLazySingleton( () => HardwareShortcutListener(sl()), ); @@ -59,8 +67,10 @@ Future initDependencies() async { sl.registerLazySingleton( () => WalkGuideRepositoryImpl(sl(), sl()), ); - sl.registerFactory(() => WalkGuideCubit(sl())); - sl.registerLazySingleton(() => SosRepositoryImpl(sl())); + sl.registerFactory( + () => WalkGuideCubit(sl())); + sl.registerLazySingleton( + () => SosRepositoryImpl(sl())); sl.registerFactory(() => SosCubit(sl())); sl.registerLazySingleton( () => NotificationRepositoryImpl(sl(), sl()), diff --git a/walkguide-mobile/walkguide_app/lib/app/router.dart b/walkguide-mobile/walkguide_app/lib/app/router.dart index 5eada23..a022a66 100644 --- a/walkguide-mobile/walkguide_app/lib/app/router.dart +++ b/walkguide-mobile/walkguide_app/lib/app/router.dart @@ -29,7 +29,8 @@ import '../features/navigation_mode/presentation/screens/navigation_mode_screen. as nav; import '../features/notifications/presentation/screens/notification_screen.dart' as notifications; -import '../features/pairing/presentation/screens/pairing_screens.dart' as pairing; +import '../features/pairing/presentation/screens/pairing_screens.dart' + as pairing; import '../features/server_connect/server_connect_server.dart' as server_connect; import '../features/settings/presentation/screens/user_settings_screen.dart' @@ -96,7 +97,17 @@ final GoRouter appRouter = GoRouter( builder: (_, __) => const auth_register.RegisterScreen()), GoRoute( path: '/incoming-call', - builder: (_, __) => const call.IncomingCallScreen()), + builder: (_, state) { + final extra = state.extra is Map + ? Map.from(state.extra as Map) + : {}; + return call.IncomingCallScreen( + callerName: extra['callerName']?.toString() ?? 'Guardian', + callerId: int.tryParse(extra['callerId']?.toString() ?? ''), + channelName: extra['channelName']?.toString(), + agoraToken: extra['agoraToken']?.toString(), + ); + }), ShellRoute( builder: (_, __, child) => UserShell(child: child), routes: [ @@ -161,6 +172,12 @@ final GoRouter appRouter = GoRouter( path: '/guardian/settings', builder: (_, __) => const guardian_settings.GuardianSettingsScreen()), + GoRoute( + path: '/guardian/call', + builder: (_, __) => const call.CallScreen( + targetLabel: 'User', + returnRoute: '/guardian/dashboard', + )), GoRoute( path: '/guardian/benchmark', builder: (_, __) => const benchmark.AiBenchmarkScreen()), diff --git a/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart b/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart index cc6cbbf..c6d2de0 100644 --- a/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart +++ b/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart @@ -61,7 +61,7 @@ class AppConstants { await prefs.setString(_selectedYoloModelKey, path); } - // Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=... - static const String agoraAppId = - String.fromEnvironment('AGORA_APP_ID', defaultValue: ''); + // Agora App ID tetap bisa dioverride saat build: --dart-define=AGORA_APP_ID=... + static const String agoraAppId = String.fromEnvironment('AGORA_APP_ID', + defaultValue: 'e36c2b6592e34cfda1f6ea6432a5e68d'); } diff --git a/walkguide-mobile/walkguide_app/lib/core/errors/friendly_error.dart b/walkguide-mobile/walkguide_app/lib/core/errors/friendly_error.dart index 14b264f..4334958 100644 --- a/walkguide-mobile/walkguide_app/lib/core/errors/friendly_error.dart +++ b/walkguide-mobile/walkguide_app/lib/core/errors/friendly_error.dart @@ -71,6 +71,10 @@ bool _looksTechnical(String message) { 'null check operator', 'nosuchmethod', 'formatexception', + 'could not execute statement', + 'duplicate key', + 'constraint', + 'sql [', ]; return blocked.any(lower.contains); } diff --git a/walkguide-mobile/walkguide_app/lib/core/services/call_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/call_service.dart index c52e5f3..06b59c7 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/call_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/call_service.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; import '../constants/app_constants.dart'; import '../network/api_client.dart'; @@ -7,9 +10,19 @@ import '../network/api_client.dart'; class CallService { final ApiClient _apiClient; RtcEngine? _engine; + VoidCallback? _onRemoteUserJoined; + VoidCallback? _onRemoteUserOffline; CallService(this._apiClient); + void setRemoteUserJoinedCallback(VoidCallback? callback) { + _onRemoteUserJoined = callback; + } + + void setRemoteUserOfflineCallback(VoidCallback? callback) { + _onRemoteUserOffline = callback; + } + Future?> requestToken({required int receiverId}) async { final res = await _apiClient.dio.post( '/shared/call/token', @@ -41,29 +54,83 @@ class CallService { }); } - Future callPairedUser({int uid = 0}) async { + Future?> startPairedCall({int uid = 0}) async { final receiverId = await getPairedReceiverId(); - if (receiverId == null) return false; + if (receiverId == null) return null; final tokenData = await requestToken(receiverId: receiverId); final channelName = tokenData?['channelName']?.toString(); final token = tokenData?['token']?.toString(); - if (channelName == null || channelName.isEmpty) return false; + final localUid = (tokenData?['uid'] as num?)?.toInt() ?? uid; + if (channelName == null || channelName.isEmpty) return null; final joined = await joinChannel( channelName: channelName, token: token, - uid: uid, + uid: localUid, ); - if (joined) { - await notifyIncomingCall( - receiverId: receiverId, - channelName: channelName, - agoraToken: token, - receiverUid: uid, - ); - } - return joined; + if (!joined) return null; + + await notifyIncomingCall( + receiverId: receiverId, + channelName: channelName, + agoraToken: token, + receiverUid: 0, + ); + + return { + 'receiverId': receiverId, + 'channelName': channelName, + 'token': token, + 'uid': localUid, + }; + } + + Future callPairedUser({int uid = 0}) async { + return await startPairedCall(uid: uid) != null; + } + + Future acceptIncomingCall({ + required int callerId, + required String channelName, + }) async { + await _apiClient.dio.post('/shared/call/accept', data: { + 'callerId': callerId.toString(), + 'channelName': channelName, + }); + } + + Future?> getAcceptedCall() async { + final res = await _apiClient.dio.get('/shared/call/accepted'); + final data = res.data['data']; + return data is Map ? Map.from(data) : null; + } + + Future?> 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.from(data) : null; + } + + Future clearAcceptedCall() async { + await _apiClient.dio.delete('/shared/call/accepted'); + } + + Future clearPendingCall() async { + await _apiClient.dio.delete('/shared/call/pending'); + } + + Future 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 joinChannel({ @@ -71,32 +138,94 @@ class CallService { String? token, int uid = 0, }) async { + final joinCompleter = Completer(); try { if (AppConstants.agoraAppId.isEmpty) { debugPrint('Agora join skipped: AGORA_APP_ID is not configured'); return false; } + if (!await _ensureMicrophonePermission()) { + debugPrint('Agora join skipped: microphone permission denied'); + return false; + } + _engine ??= createAgoraRtcEngine(); - await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId)); + await _engine!.initialize( + 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!.enableLocalAudio(true); + await _engine!.muteLocalAudioStream(false); + await _engine!.setEnableSpeakerphone(true); await _engine!.joinChannel( token: token ?? '', channelId: channelName, uid: uid, - options: const ChannelMediaOptions(), + options: const ChannelMediaOptions( + channelProfile: ChannelProfileType.channelProfileCommunication, + clientRoleType: ClientRoleType.clientRoleBroadcaster, + publishMicrophoneTrack: true, + autoSubscribeAudio: true, + ), + ); + return joinCompleter.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + debugPrint('Agora join timeout for channel $channelName'); + return false; + }, ); - return true; } catch (e) { debugPrint('Agora join skipped: $e'); return false; } } + Future _ensureMicrophonePermission() async { + if (kIsWeb) return true; + final status = await Permission.microphone.request(); + return status.isGranted || status.isLimited; + } + + Future setMuted(bool muted) async { + await _engine?.muteLocalAudioStream(muted); + } + + Future setSpeakerEnabled(bool enabled) async { + await _engine?.setEnableSpeakerphone(enabled); + } + Future leave() async { + _onRemoteUserJoined = null; + _onRemoteUserOffline = null; await _engine?.leaveChannel(); } Future dispose() async { + _onRemoteUserJoined = null; + _onRemoteUserOffline = null; await _engine?.release(); _engine = null; } diff --git a/walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart index 0ae0286..8c122d9 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart @@ -1,13 +1,17 @@ +import 'dart:convert'; + import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import '../../app/router.dart'; import '../network/api_client.dart'; class FcmService { final ApiClient _apiClient; final FirebaseMessaging _messaging = FirebaseMessaging.instance; - final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin(); + final FlutterLocalNotificationsPlugin _localNotifications = + FlutterLocalNotificationsPlugin(); FcmService(this._apiClient); @@ -18,6 +22,14 @@ class FcmService { const InitializationSettings( android: AndroidInitializationSettings('@mipmap/ic_launcher'), ), + onDidReceiveNotificationResponse: (response) { + final payload = response.payload; + if (payload == null || payload.isEmpty) return; + try { + final data = Map.from(jsonDecode(payload) as Map); + _handlePayloadNavigation(data); + } catch (_) {} + }, ); await _messaging.requestPermission(alert: true, badge: true, sound: true); final token = await _messaging.getToken(); @@ -26,7 +38,16 @@ class FcmService { FirebaseMessaging.onMessage.listen((message) { debugPrint('FCM foreground: ${message.data}'); _showLocalNotification(message); + _handlePayloadNavigation(message.data); }); + FirebaseMessaging.onMessageOpenedApp.listen((message) { + _handlePayloadNavigation(message.data); + }); + final initialMessage = + await FirebaseMessaging.instance.getInitialMessage(); + if (initialMessage != null) { + _handlePayloadNavigation(initialMessage.data); + } } catch (e) { debugPrint('FCM init skipped: $e'); } @@ -42,8 +63,11 @@ class FcmService { Future _showLocalNotification(RemoteMessage message) async { final notification = message.notification; - final title = notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide'; - final body = notification?.body ?? message.data['body']?.toString() ?? 'Ada update baru'; + final title = + notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide'; + final body = notification?.body ?? + message.data['body']?.toString() ?? + 'Ada update baru'; await _localNotifications.show( DateTime.now().millisecondsSinceEpoch ~/ 1000, title, @@ -57,7 +81,26 @@ class FcmService { priority: Priority.high, ), ), - payload: message.data['type']?.toString(), + payload: jsonEncode(message.data), ); } + + void _handlePayloadNavigation(Map 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'); + } + } } diff --git a/walkguide-mobile/walkguide_app/lib/core/services/incoming_call_polling_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/incoming_call_polling_service.dart new file mode 100644 index 0000000..361ee5b --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/services/incoming_call_polling_service.dart @@ -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 _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.from(data)); + } catch (e) { + debugPrint('Incoming call polling skipped: $e'); + } + } +} \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart index dc435b6..aaa205e 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart @@ -13,9 +13,9 @@ import '../storage/secure_storage.dart'; /// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user. /// /// Subscriptions yang dipakai: -/// Guardian → /topic/location/{userId} live GPS update -/// Guardian → /queue/sos/{guardianId} SOS alert real-time -/// User → /queue/notif/{userId} notifikasi dari Guardian +/// Guardian → /topic/location/{userId} live GPS update +/// Guardian → /queue/sos/{guardianId} SOS alert real-time +/// User → /queue/notif/{userId} notifikasi dari Guardian class WebSocketService { final SecureStorage _storage; @@ -26,11 +26,13 @@ class WebSocketService { void Function(double lat, double lng)? _onLocation; void Function(Map sosData)? _onSos; void Function(Map notifData)? _onNotif; + void Function(Map callData)? _onCall; // Subscription frames (untuk unsubscribe) StompUnsubscribe? _locationUnsub; StompUnsubscribe? _sosUnsub; StompUnsubscribe? _notifUnsub; + StompUnsubscribe? _callUnsub; WebSocketService(this._storage); @@ -88,18 +90,18 @@ class WebSocketService { await completer.future.timeout(const Duration(seconds: 5)); } catch (e) { debugPrint('[WS] Connect timeout/error: $e'); - // Don't throw — let dashboard work without WS + // Don't throw — let dashboard work without WS } } /// Subscribe ke live GPS updates dari User. /// Guardian panggil ini setelah connect. /// [userId] = ID dari ROLE_USER yang dipair. - void subscribeLocation(String userId, - void Function(double lat, double lng) callback) { + void subscribeLocation( + String userId, void Function(double lat, double lng) callback) { _onLocation = callback; if (_client == null || !_connected) { - debugPrint('[WS] subscribeLocation skipped — not connected'); + debugPrint('[WS] subscribeLocation skipped — not connected'); return; } _locationUnsub?.call(); // unsubscribe sebelumnya jika ada @@ -107,8 +109,7 @@ class WebSocketService { destination: '/topic/location/$userId', callback: (frame) { try { - final data = - jsonDecode(frame.body ?? '{}') as Map; + final data = jsonDecode(frame.body ?? '{}') as Map; final lat = (data['lat'] as num?)?.toDouble(); final lng = (data['lng'] as num?)?.toDouble(); if (lat != null && lng != null) { @@ -135,8 +136,7 @@ class WebSocketService { destination: '/queue/sos/$guardianId', callback: (frame) { try { - final data = - jsonDecode(frame.body ?? '{}') as Map; + final data = jsonDecode(frame.body ?? '{}') as Map; _onSos?.call(data); } catch (e) { debugPrint('[WS] SOS parse error: $e'); @@ -147,7 +147,7 @@ class WebSocketService { }); } - /// Subscribe ke notifikasi Guardian → User. + /// Subscribe ke notifikasi Guardian → User. /// [userId] = ID dari ROLE_USER yang login. void subscribeNotification( void Function(Map notifData) callback) { @@ -161,8 +161,7 @@ class WebSocketService { destination: '/queue/notif/$userId', callback: (frame) { try { - final data = - jsonDecode(frame.body ?? '{}') as Map; + final data = jsonDecode(frame.body ?? '{}') as Map; _onNotif?.call(data); } catch (e) { debugPrint('[WS] Notif parse error: $e'); @@ -173,20 +172,46 @@ class WebSocketService { }); } + /// Subscribe ke panggilan masuk realtime. Ini melengkapi FCM agar call tetap + /// masuk saat app foreground atau ketika FCM di app clone tidak stabil. + void subscribeCall(void Function(Map 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; + _onCall?.call(data); + } catch (e) { + debugPrint('[WS] Call parse error: $e'); + } + }, + ); + debugPrint('[WS] Subscribed to /queue/call/$userId'); + }); + } + /// Disconnect dan cleanup semua subscriptions. Future disconnect() async { _locationUnsub?.call(); _sosUnsub?.call(); _notifUnsub?.call(); + _callUnsub?.call(); _locationUnsub = null; _sosUnsub = null; _notifUnsub = null; + _callUnsub = null; _client?.deactivate(); _client = null; _connected = false; } - // Legacy compat — lama pakai onMessage raw + // Legacy compat — lama pakai onMessage raw void send(Object message) { debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.'); } diff --git a/walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart b/walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart index cd853ef..a32bebf 100644 --- a/walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart +++ b/walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; 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 success = Color(0xFF16A34A); - static const surface = Color(0xFFF8FAFC); + static const success = Color(0xFF059669); + static const surface = Color(0xFFF7FAFC); + static const surfaceRaised = Color(0xFFFFFFFF); static const text = Color(0xFF0F172A); static const muted = Color(0xFF64748B); + static const border = Color(0xFFE2E8F0); } diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart index b091280..12aa434 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart @@ -8,10 +8,13 @@ import 'package:go_router/go_router.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../app/app_cubit.dart'; +import '../../app/router.dart'; import '../../app/injection_container.dart'; import '../../core/constants/app_constants.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; +import '../../core/services/fcm_service.dart'; +import '../../core/services/incoming_call_polling_service.dart'; import '../../core/services/offline_queue_service.dart'; import '../../core/services/tts_service.dart'; import '../../core/services/websocket_service.dart'; @@ -225,7 +228,12 @@ class _AuthFrame extends StatelessWidget { width: 56, height: 56, decoration: BoxDecoration( - color: const Color(0xFF1D4ED8), + gradient: const LinearGradient( + colors: [ + Color(0xFF2563EB), + Color(0xFF0891B2) + ], + ), borderRadius: BorderRadius.circular(18), ), 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( title, style: Theme.of(context) @@ -311,9 +344,16 @@ Future _saveAuthAndRoute( void _startPostLoginServices(String serverUrl) { Future.microtask(() async { - await sl() - .connect(serverUrl) - .timeout(const Duration(seconds: 2)); + sl().start(); + await sl().init().timeout(const Duration(seconds: 4)); + final ws = sl(); + 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() .syncPending(sl()) .timeout(const Duration(seconds: 3)); diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart index 482395a..73dad1c 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; +import '../../core/services/incoming_call_polling_service.dart'; import '../../core/storage/secure_storage.dart'; // --------------------------------------------------------------------------- @@ -70,6 +71,7 @@ class _SplashScreenState extends State return; } + sl().start(); // Auto-login: arahkan ke home sesuai role. context.go(role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' diff --git a/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart b/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart index 881f12d..c53e487 100644 --- a/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart @@ -1,11 +1,4 @@ -// ignore_for_file: use_build_context_synchronously, prefer_const_constructors -// lib/features/call/call_screen.dart -// -// CallScreen — user memanggil Guardian via Agora -// IncomingCallScreen — Guardian/User menerima panggilan masuk -// -// Keduanya pakai CallService yang sudah ada (agora_rtc_engine). - +// ignore_for_file: use_build_context_synchronously import 'dart:async'; import 'package:flutter/material.dart'; @@ -15,18 +8,23 @@ import '../../app/injection_container.dart'; import '../../core/services/call_service.dart'; import '../../core/services/haptic_service.dart'; import '../../core/services/tts_service.dart'; +import '../../core/storage/secure_storage.dart'; -// ─── Colours ───────────────────────────────────────────────────────────────── const _kBlue = Color(0xFF1A56DB); const _kGreen = Color(0xFF16A34A); const _kRed = Color(0xFFDC2626); const _kMuted = Color(0xFF64748B); -const _kBg = Color(0xFF0F172A); // dark bg untuk call screen - -// ─── CallScreen ─────────────────────────────────────────────────────────────── +const _kBg = Color(0xFF0F172A); 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 State createState() => _CallScreenState(); @@ -38,64 +36,153 @@ class _CallScreenState extends State bool _muted = false; bool _speakerOn = true; int _secondsElapsed = 0; + int? _otherId; + String? _activeChannel; Timer? _timer; + Timer? _ringTimeout; + Timer? _acceptedPoll; - // animasi pulse saat ringing - late AnimationController _pulseCtrl; - late Animation _pulseScale; + late final AnimationController _pulseCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + late final Animation _pulseScale = Tween(begin: 0.95, end: 1.08) + .animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); @override void initState() { super.initState(); - _pulseCtrl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1200), - )..repeat(reverse: true); - _pulseScale = Tween(begin: 0.95, end: 1.08) - .animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); - - sl().speak('Memanggil Guardian.'); - _startCall(); + sl().speak('Memanggil ${widget.targetLabel}.'); + unawaited(_startCall()); } Future _startCall() async { - final joined = await sl().callPairedUser(); + final callService = sl(); + 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) { - setState(() => _phase = _CallPhase.connected); - sl().speak('Terhubung dengan Guardian.'); - _pulseCtrl.stop(); - _startTimer(); - } else { - setState(() => _phase = _CallPhase.failed); - sl() - .speak('Panggilan gagal. Pastikan Agora sudah dikonfigurasi.'); + _otherId = _asInt(invite['receiverId']); + _activeChannel = invite['channelName']?.toString(); + setState(() => _phase = _CallPhase.calling); + sl().speak( + 'Panggilan dikirim ke ${widget.targetLabel}. Menunggu jawaban.', + ); + _startAcceptedPolling(); + _ringTimeout?.cancel(); + _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() + .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() + .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().speak('Terhubung dengan ${widget.targetLabel}.'); + _pulseCtrl.stop(); + _startTimer(); + } + + void _failCall(String message) { + _acceptedPoll?.cancel(); + _ringTimeout?.cancel(); + sl().setRemoteUserJoinedCallback(null); + sl().setRemoteUserOfflineCallback(null); + setState(() => _phase = _CallPhase.failed); + _pulseCtrl.stop(); + sl().speak(message); + } + void _startTimer() { + _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() => _secondsElapsed++); }); } + Future _finishRemoteEnded() async { + if (!mounted) return; + _timer?.cancel(); + _ringTimeout?.cancel(); + _acceptedPoll?.cancel(); + await sl().leave(); + sl().speak('Panggilan diakhiri oleh lawan bicara.'); + if (mounted) context.go(widget.returnRoute); + } + Future _endCall() async { _timer?.cancel(); - await sl().leave(); + _ringTimeout?.cancel(); + _acceptedPoll?.cancel(); + final callService = sl(); + callService.setRemoteUserJoinedCallback(null); + callService.setRemoteUserOfflineCallback(null); + await callService.endCall(_otherId, channelName: _activeChannel); + await callService.leave(); sl().speak('Panggilan diakhiri.'); - if (mounted) context.go('/user/walkguide'); + if (mounted) context.go(widget.returnRoute); } Future _toggleMute() async { setState(() => _muted = !_muted); - // Agora engine mute via CallService jika ada — di sini cukup state lokal - // sl().muteLocalAudio(_muted); + await sl().setMuted(_muted); } - void _toggleSpeaker() { + Future _toggleSpeaker() async { setState(() => _speakerOn = !_speakerOn); + await sl().setSpeakerEnabled(_speakerOn); } String get _timerLabel { @@ -107,183 +194,370 @@ class _CallScreenState extends State @override void dispose() { _timer?.cancel(); + _ringTimeout?.cancel(); + _acceptedPoll?.cancel(); + sl().setRemoteUserJoinedCallback(null); + sl().setRemoteUserOfflineCallback(null); _pulseCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: _kBg, - body: SafeArea( - child: Column( - children: [ - // ── top bar ────────────────────────────────────────────────── - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - IconButton( - onPressed: () => context.go('/user/walkguide'), - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.white54), - ), - const Expanded( - child: Text('Panggilan', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white70, - fontWeight: FontWeight.w600)), - ), - const SizedBox(width: 48), // balance - ], - ), + return _CallScaffold( + title: 'Panggilan', + child: Column( + children: [ + const Spacer(), + AnimatedBuilder( + animation: _pulseCtrl, + builder: (_, child) => Transform.scale( + scale: _phase == _CallPhase.calling ? _pulseScale.value : 1.0, + child: child, ), - - const Spacer(), - - // ── 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), - ), + child: _Avatar( + icon: Icons.shield_outlined, + color: _phase == _CallPhase.failed ? _kRed : _kBlue, ), - - const SizedBox(height: 20), - - const Text('Guardian', - style: TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.w800)), - - const SizedBox(height: 8), - - _PhaseLabel(phase: _phase, timerLabel: _timerLabel), - - const Spacer(), - - // ── controls ───────────────────────────────────────────────── - if (_phase == _CallPhase.connected) ...[ - 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), - ], - - 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), + ), + const SizedBox(height: 20), + Text( + widget.targetLabel, + style: const TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + _PhaseLabel(phase: _phase, timerLabel: _timerLabel), + const Spacer(), + if (_phase == _CallPhase.connected) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ControlButton( + icon: _muted ? Icons.mic_off : Icons.mic, + label: _muted ? 'Unmute' : 'Mute', + onTap: _toggleMute, + active: _muted, ), - ), - const SizedBox(height: 24), - ], - - // ── end call button ─────────────────────────────────────────── - _EndCallButton(onTap: _endCall), - - const SizedBox(height: 48), + _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) ...[ + 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 { - /// callerName bisa diisi dari FCM payload via extra go_router params. - /// Default 'Guardian' jika tidak ada. 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 State createState() => _IncomingCallScreenState(); } class _IncomingCallScreenState extends State { - static const _autoAnswerSeconds = 30; - int _countdown = _autoAnswerSeconds; - Timer? _autoTimer; + int _secondsElapsed = 0; + Timer? _callTimer; + Timer? _statePoll; bool _responding = false; + bool _connected = false; + bool _failed = false; + bool _muted = false; + bool _speakerOn = true; + String? _joinedChannel; @override void initState() { super.initState(); sl().callIncoming(); sl().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 void dispose() { - _autoTimer?.cancel(); + _callTimer?.cancel(); + _statePoll?.cancel(); super.dispose(); } Future _accept() async { if (_responding) return; setState(() => _responding = true); - _autoTimer?.cancel(); sl().speak('Menerima panggilan.'); - // Gabung ke channel yang sama (nama channel dari FCM payload — sementara hardcode) - await sl().joinChannel(channelName: 'walkguide-call'); - if (mounted) context.go('/user/call'); + + final joined = await _joinIncomingChannel(); + if (!mounted) return; + if (!joined || _joinedChannel == null || widget.callerId == null) { + setState(() { + _failed = true; + _responding = false; + }); + sl().speak('Panggilan gagal tersambung.'); + return; + } + + await sl().acceptIncomingCall( + callerId: widget.callerId!, + channelName: _joinedChannel!, + ); + + setState(() { + _connected = true; + _responding = false; + }); + _callTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() => _secondsElapsed++); + }); + _startIncomingStatePolling(); + sl().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() + .getCallState(_joinedChannel) + .timeout(const Duration(seconds: 3)); + if (state?['status']?.toString() == 'ENDED') { + await _finishIncomingRemoteEnded(); + } + } catch (_) {} + }); + } + + Future _finishIncomingRemoteEnded() async { + if (!mounted) return; + _callTimer?.cancel(); + _statePoll?.cancel(); + await sl().leave(); + sl().speak('Panggilan diakhiri oleh lawan bicara.'); + if (mounted) context.go(await _homeRoute()); } Future _decline() async { if (_responding) return; setState(() => _responding = true); - _autoTimer?.cancel(); sl().speak('Panggilan ditolak.'); + sl().setRemoteUserOfflineCallback(null); + await sl() + .endCall(widget.callerId, channelName: widget.channelName); + await sl().clearPendingCall(); await sl().leave(); - if (mounted) context.go('/user/walkguide'); + if (mounted) context.go(await _homeRoute()); } + Future _joinIncomingChannel() async { + sl().setRemoteUserOfflineCallback(() { + unawaited(_finishIncomingRemoteEnded()); + }); + if (widget.callerId != null) { + final tokenData = + await sl().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().joinChannel( + channelName: channelName, + token: token, + uid: uid, + ); + } + } + + final fallbackChannel = widget.channelName; + if (fallbackChannel == null || fallbackChannel.isEmpty) return false; + _joinedChannel = fallbackChannel; + return sl().joinChannel( + channelName: fallbackChannel, + token: widget.agoraToken, + ); + } + + Future _endConnectedCall() async { + _callTimer?.cancel(); + _statePoll?.cancel(); + sl().setRemoteUserOfflineCallback(null); + await sl() + .endCall(widget.callerId, channelName: _joinedChannel); + await sl().leave(); + sl().speak('Panggilan diakhiri.'); + if (mounted) context.go(await _homeRoute()); + } + + Future _toggleMute() async { + setState(() => _muted = !_muted); + await sl().setMuted(_muted); + } + + Future _toggleSpeaker() async { + setState(() => _speakerOn = !_speakerOn); + await sl().setSpeakerEnabled(_speakerOn); + } + + Future _homeRoute() async { + final role = await sl().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 Widget build(BuildContext context) { return Scaffold( @@ -291,55 +565,26 @@ class _IncomingCallScreenState extends State { body: SafeArea( child: Column( 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: const EdgeInsets.symmetric(horizontal: 48), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Decline - _RoundCallButton( - icon: Icons.call_end, - color: _kRed, - label: 'Tolak', - onTap: _responding ? null : _decline, - ), - // Accept - _RoundCallButton( - icon: Icons.call, - color: _kGreen, - label: 'Terima', - onTap: _responding ? null : _accept, + const SizedBox(width: 48), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w600, + ), + ), ), + const SizedBox(width: 48), ], ), ), - - const SizedBox(height: 56), + Expanded(child: child), ], ), ), @@ -347,42 +592,73 @@ class _IncomingCallScreenState extends State { } } -// ─── Sub-widgets ────────────────────────────────────────────────────────────── - enum _CallPhase { calling, connected, failed } class _PhaseLabel extends StatelessWidget { final _CallPhase phase; final String timerLabel; + const _PhaseLabel({required this.phase, required this.timerLabel}); @override Widget build(BuildContext context) { switch (phase) { case _CallPhase.calling: - return const Text('Memanggil…', - style: TextStyle(color: _kMuted, fontSize: 16)); + return const Text( + 'Memanggil...', + style: TextStyle(color: _kMuted, fontSize: 16), + ); case _CallPhase.connected: - return Text(timerLabel, - style: const TextStyle( - color: _kGreen, fontSize: 22, fontWeight: FontWeight.w700)); + return Text( + timerLabel, + style: const TextStyle( + color: _kGreen, + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ); case _CallPhase.failed: - return const Text('Panggilan gagal', - style: TextStyle(color: _kRed, fontSize: 16)); + return const Text( + '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 { final IconData icon; final String label; final VoidCallback onTap; final bool active; - const _ControlButton( - {required this.icon, - required this.label, - required this.onTap, - this.active = false}); + + const _ControlButton({ + required this.icon, + required this.label, + required this.onTap, + this.active = false, + }); @override Widget build(BuildContext context) { @@ -402,8 +678,7 @@ class _ControlButton extends StatelessWidget { child: Icon(icon, color: Colors.white, size: 28), ), const SizedBox(height: 6), - Text(label, - style: const TextStyle(color: Colors.white54, fontSize: 12)), + Text(label, style: const TextStyle(color: Colors.white54)), ], ), ); @@ -412,6 +687,7 @@ class _ControlButton extends StatelessWidget { class _EndCallButton extends StatelessWidget { final VoidCallback onTap; + const _EndCallButton({required this.onTap}); @override @@ -421,17 +697,14 @@ class _EndCallButton extends StatelessWidget { child: Column( children: [ Container( - width: 72, - height: 72, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: _kRed, - ), + width: 74, + height: 74, + decoration: + const BoxDecoration(shape: BoxShape.circle, color: _kRed), child: const Icon(Icons.call_end, color: Colors.white, size: 32), ), const SizedBox(height: 6), - const Text('Akhiri', - style: TextStyle(color: Colors.white54, fontSize: 12)), + const Text('Akhiri', style: TextStyle(color: Colors.white54)), ], ), ); @@ -443,32 +716,38 @@ class _RoundCallButton extends StatelessWidget { final Color color; final String label; final VoidCallback? onTap; - const _RoundCallButton( - {required this.icon, - required this.color, - required this.label, - this.onTap}); + + const _RoundCallButton({ + required this.icon, + required this.color, + required this.label, + this.onTap, + }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Opacity( - opacity: onTap == null ? 0.4 : 1.0, + opacity: onTap == null ? 0.4 : 1, child: Column( children: [ Container( - width: 72, - height: 72, + width: 74, + height: 74, decoration: BoxDecoration(shape: BoxShape.circle, color: color), child: Icon(icon, color: Colors.white, size: 32), ), const SizedBox(height: 8), - Text(label, - style: const TextStyle(color: Colors.white70, fontSize: 13)), + Text(label, style: const TextStyle(color: Colors.white70)), ], ), ), ); } } + +int? _asInt(dynamic value) { + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); +} diff --git a/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart b/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart index 93780be..6306255 100644 --- a/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart @@ -12,14 +12,15 @@ import 'package:latlong2/latlong.dart'; import '../../../app/injection_container.dart'; import '../../../core/network/api_client.dart'; import '../../../core/services/websocket_service.dart'; +import '../../../core/services/incoming_call_polling_service.dart'; import '../../../core/storage/secure_storage.dart'; import '../../../core/utils/operation_guard.dart'; -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // GUARDIAN DASHBOARD SCREEN -// Fully live — fetches real data from backend, subscribes to WebSocket +// Fully live — fetches real data from backend, subscribes to WebSocket // for real-time GPS updates and SOS alerts. -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── class GuardianDashboardScreen extends StatefulWidget { const GuardianDashboardScreen({super.key}); @@ -31,18 +32,18 @@ class GuardianDashboardScreen extends StatefulWidget { class _GuardianDashboardScreenState extends State with TickerProviderStateMixin { - // ── Data state ────────────────────────────────────────────────────────────── + // ── Data state ────────────────────────────────────────────────────────────── _DashboardData? _data; bool _loading = true; String? _error; String _guardianName = 'Guardian'; - // ── Live location (WebSocket) ──────────────────────────────────────────────── + // ── Live location (WebSocket) ──────────────────────────────────────────────── LatLng? _liveLatLng; bool _liveConnected = false; final MapController _mapController = MapController(); - // ── Pulse animation for live dot ──────────────────────────────────────────── + // ── Pulse animation for live dot ──────────────────────────────────────────── late final AnimationController _pulseCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1800), @@ -52,15 +53,16 @@ class _GuardianDashboardScreenState extends State CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut), ); - // ── SOS flash animation ────────────────────────────────────────────────────── + // ── SOS flash animation ────────────────────────────────────────────────────── late final AnimationController _sosCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); bool _sosAlert = false; + bool _handlingSos = false; List> _pendingSos = const []; - // ── Refresh button animation ───────────────────────────────────────────────── + // ── Refresh button animation ───────────────────────────────────────────────── late final AnimationController _refreshCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 700), @@ -70,6 +72,7 @@ class _GuardianDashboardScreenState extends State void initState() { super.initState(); _loadAll(); + sl().start(); _subscribeWebSocket(); } @@ -83,7 +86,7 @@ class _GuardianDashboardScreenState extends State Dio get _api => sl().dio; - // ── Load all dashboard data in parallel ──────────────────────────────────── + // ── Load all dashboard data in parallel ──────────────────────────────────── Future _loadAll({bool silent = false}) async { if (!silent) { setState(() { @@ -93,83 +96,76 @@ class _GuardianDashboardScreenState extends State } await guarded( () async { - _guardianName = - await sl().getDisplayName() ?? 'Guardian'; + _guardianName = + await sl().getDisplayName() ?? 'Guardian'; - // Run dashboard + activity + SOS in parallel - final results = await Future.wait([ - _fetchDashboard(), - _fetchActivity(), - _fetchSosPending(), - ]); + // Run dashboard + activity + SOS in parallel + final results = await Future.wait([ + _fetchDashboard(), + _fetchActivity(), + _fetchSosPending(), + ]); - final dashboard = results[0] as Map?; - final activityList = - results[1] as List>; - final sosPendingEvents = results[2] as List>; - final sosPending = sosPendingEvents.length; + final dashboard = results[0] as Map?; + final activityList = results[1] as List>; + final sosPendingEvents = results[2] as List>; + final sosPending = sosPendingEvents.length; - // Extract latest GPS from dashboard - final lastLoc = dashboard?['lastLocation'] as Map?; - LatLng? newLatLng; - if (lastLoc != null && - lastLoc['lat'] != null && - lastLoc['lng'] != null) { - newLatLng = LatLng( - (lastLoc['lat'] as num).toDouble(), - (lastLoc['lng'] as num).toDouble(), - ); - } - - // Extract user info - final userStatus = - dashboard?['userStatus'] as Map?; - - setState(() { - _data = _DashboardData( - userName: userStatus?['displayName']?.toString() ?? - dashboard?['userName']?.toString() ?? - 'User', - userOnline: - userStatus?['online'] as bool? ?? false, - userLastSeen: - userStatus?['lastSeenAt']?.toString(), - battery: userStatus?['battery'] as int?, - speed: userStatus?['lastSpeed'] as double?, - obstaclesTotal: - userStatus?['obstaclesToday'] as int? ?? - dashboard?['obstaclesToday'] as int? ?? - 0, - unreadNotif: - dashboard?['unreadNotifCount'] as int? ?? 0, - unreadSos: sosPending, - lastLat: lastLoc?['lat'] != null - ? (lastLoc!['lat'] as num).toDouble() - : null, - lastLng: lastLoc?['lng'] != null - ? (lastLoc!['lng'] as num).toDouble() - : null, - lastLocationTime: - lastLoc?['createdAt']?.toString(), - recentActivity: activityList, - isPaired: userStatus != null || dashboard != null, - ); - _pendingSos = sosPendingEvents; - if (newLatLng != null) { - _liveLatLng = newLatLng; + // Extract latest GPS from dashboard + final lastLoc = dashboard?['lastLocation'] as Map?; + LatLng? newLatLng; + if (lastLoc != null && + lastLoc['lat'] != null && + lastLoc['lng'] != null) { + newLatLng = LatLng( + (lastLoc['lat'] as num).toDouble(), + (lastLoc['lng'] as num).toDouble(), + ); } - _loading = false; - }); - // If SOS pending, start flash - if (sosPending > 0 && !_sosAlert) { - _triggerSosFlash(); - } + // Extract user info + final userStatus = dashboard?['userStatus'] as Map?; - // Move map to latest location - if (newLatLng != null) { - _moveMapSafely(newLatLng); - } + setState(() { + _data = _DashboardData( + userName: userStatus?['displayName']?.toString() ?? + dashboard?['userName']?.toString() ?? + 'User', + userOnline: userStatus?['online'] as bool? ?? false, + userLastSeen: userStatus?['lastSeenAt']?.toString(), + battery: userStatus?['battery'] as int?, + speed: userStatus?['lastSpeed'] as double?, + obstaclesTotal: userStatus?['obstaclesToday'] as int? ?? + dashboard?['obstaclesToday'] as int? ?? + 0, + unreadNotif: dashboard?['unreadNotifCount'] as int? ?? 0, + unreadSos: sosPending, + lastLat: lastLoc?['lat'] != null + ? (lastLoc!['lat'] as num).toDouble() + : null, + lastLng: lastLoc?['lng'] != null + ? (lastLoc!['lng'] as num).toDouble() + : null, + lastLocationTime: lastLoc?['createdAt']?.toString(), + recentActivity: activityList, + isPaired: userStatus != null || dashboard != null, + ); + _pendingSos = sosPendingEvents; + if (newLatLng != null) { + _liveLatLng = newLatLng; + } + _loading = false; + }); + + // If SOS pending, start flash + if (sosPending > 0 && !_sosAlert) { + _triggerSosFlash(); + } + + // Move map to latest location + if (newLatLng != null) { + _moveMapSafely(newLatLng); + } }, onError: (e) => setState(() { _loading = false; @@ -181,91 +177,90 @@ class _GuardianDashboardScreenState extends State Future?> _fetchDashboard() async { return await guarded?>( () async { - final res = await _api - .get('/guardian/dashboard') - .timeout(const Duration(seconds: 8)); - final d = res.data['data']; - return d is Map ? Map.from(d) : null; + final res = await _api + .get('/guardian/dashboard') + .timeout(const Duration(seconds: 8)); + final d = res.data['data']; + return d is Map ? Map.from(d) : null; }, ); } Future>> _fetchActivity() async { return await guarded>>( - () async { - final res = await _api - .get('/guardian/activity-logs', - queryParameters: {'size': 5, 'page': 0}) - .timeout(const Duration(seconds: 8)); - final data = res.data['data']; - final content = - data is Map ? data['content'] : null; - if (content is List) { - return content - .whereType() - .map((e) => Map.from(e)) - .toList(); - } - return const []; - }, - ) ?? + () async { + final res = await _api.get('/guardian/activity-logs', + queryParameters: { + 'size': 5, + 'page': 0 + }).timeout(const Duration(seconds: 8)); + final data = res.data['data']; + final content = data is Map ? data['content'] : null; + if (content is List) { + return content + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + return const []; + }, + ) ?? const []; } Future>> _fetchSosPending() async { return await guarded>>( - () async { - final res = await _api - .get('/guardian/sos-events', - queryParameters: {'size': 10, 'page': 0}) - .timeout(const Duration(seconds: 8)); - final data = res.data['data']; - final content = - data is Map ? data['content'] : null; - if (content is List) { - return content - .whereType() - .where((e) => e['status'] == 'TRIGGERED') - .map((e) => Map.from(e)) - .toList(); - } - return const []; - }, - ) ?? + () async { + final res = await _api.get('/guardian/sos-events', + queryParameters: { + 'size': 10, + 'page': 0 + }).timeout(const Duration(seconds: 8)); + final data = res.data['data']; + final content = data is Map ? data['content'] : null; + if (content is List) { + return content + .whereType() + .where((e) => e['status'] == 'TRIGGERED') + .map((e) => Map.from(e)) + .toList(); + } + return const []; + }, + ) ?? const []; } - // ── WebSocket subscription ────────────────────────────────────────────────── + // ── WebSocket subscription ────────────────────────────────────────────────── void _subscribeWebSocket() { final ws = sl(); Future.microtask(() async { await guarded( () async { - final userId = await _getLinkedUserId(); - if (userId == null) return; - ws.subscribeLocation(userId, (lat, lng) { - if (!mounted) return; - final newPos = LatLng(lat, lng); - setState(() { - _liveLatLng = newPos; - _liveConnected = true; + final userId = await _getLinkedUserId(); + if (userId == null) return; + ws.subscribeLocation(userId, (lat, lng) { + if (!mounted) return; + final newPos = LatLng(lat, lng); + setState(() { + _liveLatLng = newPos; + _liveConnected = true; + }); + _moveMapSafely(newPos); }); - _moveMapSafely(newPos); - }); - ws.subscribeSos((sosData) { - if (!mounted) return; - _triggerSosFlash(); - setState(() { - _pendingSos = [ - Map.from(sosData), - ..._pendingSos, - ]; - _data = _data?.copyWith( - unreadSos: (_data?.unreadSos ?? 0) + 1); + ws.subscribeSos((sosData) { + if (!mounted) return; + _triggerSosFlash(); + setState(() { + _pendingSos = [ + Map.from(sosData), + ..._pendingSos, + ]; + _data = _data?.copyWith(unreadSos: (_data?.unreadSos ?? 0) + 1); + }); + _showSosSnackbar(sosData); }); - _showSosSnackbar(sosData); - }); - if (mounted) setState(() => _liveConnected = true); + if (mounted) setState(() => _liveConnected = true); }, ); }); @@ -274,15 +269,14 @@ class _GuardianDashboardScreenState extends State Future _getLinkedUserId() async { return await guarded( () async { - final res = await _api - .get('/shared/pairing/status') - .timeout(const Duration(seconds: 5)); - final d = res.data['data']; - if (d is Map && d['status'] == 'ACTIVE') { - return d['pairedWithId']?.toString() ?? - d['userId']?.toString(); - } - return null; + final res = await _api + .get('/shared/pairing/status') + .timeout(const Duration(seconds: 5)); + final d = res.data['data']; + if (d is Map && d['status'] == 'ACTIVE') { + return d['pairedWithId']?.toString() ?? d['userId']?.toString(); + } + return null; }, ); } @@ -301,33 +295,16 @@ class _GuardianDashboardScreenState extends State } void _showSosSnackbar(Map data) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: const Color(0xFFDC2626), - duration: const Duration(seconds: 8), - content: Row(children: [ - const Icon(Icons.warning_rounded, - color: Colors.white), - const SizedBox(width: 10), - Expanded( - child: Text( - '🚨 SOS dari User! Koordinat: ${data['lat']?.toStringAsFixed(4) ?? '-'}, ${data['lng']?.toStringAsFixed(4) ?? '-'}', - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w700), - ), - ), - ]), - action: SnackBarAction( - label: 'Tangani', - textColor: Colors.white, - onPressed: _handleLatestSos, - ), - ), - ); + // Keep SOS inside the dashboard card. SnackBar overlays were blocking taps + // on some Android builds until orientation changed. + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + if (!_sosAlert) { + _triggerSosFlash(); + } } Future _handleLatestSos() async { + if (_handlingSos) return; final sosId = _pendingSos .map((e) => e['id']) .where((id) => id != null) @@ -337,32 +314,36 @@ class _GuardianDashboardScreenState extends State await _loadAll(silent: true); return; } + setState(() => _handlingSos = true); await guarded( () async { - await _api - .put('/guardian/sos/$sosId/resolve') - .timeout(const Duration(seconds: 8)); - if (!mounted) return; - setState(() { - _pendingSos = - _pendingSos.where((e) => e['id']?.toString() != '$sosId').toList(); - _data = _data?.copyWith(unreadSos: _pendingSos.length); - if (_pendingSos.isEmpty) { - _sosAlert = false; - _sosCtrl.stop(); - } - }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('SOS ditandai sudah ditangani.')), - ); + await _api + .put('/guardian/sos/$sosId/acknowledge') + .timeout(const Duration(seconds: 8)); + if (!mounted) return; + setState(() { + _pendingSos = _pendingSos + .where((e) => e['id']?.toString() != '$sosId') + .toList(); + _data = _data?.copyWith(unreadSos: _pendingSos.length); + if (_pendingSos.isEmpty) { + _sosAlert = false; + _sosCtrl.stop(); + } + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('SOS diterima. Status berubah menjadi ditangani.')), + ); }, onError: (_) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Gagal menandai SOS. Coba refresh.')), - ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Gagal menandai SOS. Coba refresh.')), + ); }, ); + if (mounted) setState(() => _handlingSos = false); } void _moveMapSafely(LatLng position) { @@ -387,9 +368,9 @@ class _GuardianDashboardScreenState extends State return 'Gagal memuat dashboard. Coba refresh.'; } - // ───────────────────────────────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────────────── // BUILD - // ───────────────────────────────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { return Scaffold( @@ -398,7 +379,6 @@ class _GuardianDashboardScreenState extends State child: Column( children: [ _buildTopBar(), - if (_sosAlert) _buildSosBanner(), Expanded( child: _loading ? _buildSkeleton() @@ -408,15 +388,17 @@ class _GuardianDashboardScreenState extends State onRefresh: _refresh, color: const Color(0xFF1A56DB), child: SingleChildScrollView( - physics: - const AlwaysScrollableScrollPhysics(), + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildGreetingRow(), const SizedBox(height: 14), + if (_sosAlert || _pendingSos.isNotEmpty) ...[ + _buildSosBanner(), + const SizedBox(height: 14), + ], _buildKpiStrip(), const SizedBox(height: 14), _buildMainRow(), @@ -436,16 +418,14 @@ class _GuardianDashboardScreenState extends State ); } - // ── Top bar ───────────────────────────────────────────────────────────────── + // ── Top bar ───────────────────────────────────────────────────────────────── Widget _buildTopBar() { return Container( height: 56, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: const BoxDecoration( color: Colors.white, - border: Border( - bottom: - BorderSide(color: Color(0xFFE2E8F0), width: 1)), + border: Border(bottom: BorderSide(color: Color(0xFFE2E8F0), width: 1)), ), child: Row(children: [ // Logo @@ -471,8 +451,7 @@ class _GuardianDashboardScreenState extends State color: const Color(0xFF0F172A))), const SizedBox(width: 4), Container( - padding: - const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), decoration: BoxDecoration( color: const Color(0xFFEFF6FF), borderRadius: BorderRadius.circular(20), @@ -494,8 +473,7 @@ class _GuardianDashboardScreenState extends State decoration: BoxDecoration( shape: BoxShape.circle, color: _liveConnected - ? Color.fromRGBO( - 22, 163, 74, _pulseAnim.value) + ? Color.fromRGBO(22, 163, 74, _pulseAnim.value) : const Color(0xFF94A3B8), boxShadow: _liveConnected ? [ @@ -522,7 +500,24 @@ class _GuardianDashboardScreenState extends State ), ]), ), - const SizedBox(width: 12), + const SizedBox(width: 8), + SizedBox( + width: 40, + height: 40, + child: FilledButton( + onPressed: () => context.go('/guardian/call'), + style: FilledButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: const Color(0xFF16A34A), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Icon(Icons.call_rounded, size: 20), + ), + ), + const SizedBox(width: 8), // Refresh RotationTransition( turns: _refreshCtrl, @@ -539,62 +534,131 @@ class _GuardianDashboardScreenState extends State radius: 16, backgroundColor: const Color(0xFF1A56DB), child: Text( - _guardianName.isNotEmpty - ? _guardianName[0].toUpperCase() - : 'G', + _guardianName.isNotEmpty ? _guardianName[0].toUpperCase() : 'G', style: GoogleFonts.outfit( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w700), + color: Colors.white, fontSize: 13, fontWeight: FontWeight.w700), ), ), ]), ); } - // ── SOS banner ────────────────────────────────────────────────────────────── + // ── SOS banner ────────────────────────────────────────────────────────────── Widget _buildSosBanner() { + final count = + _pendingSos.isNotEmpty ? _pendingSos.length : (_data?.unreadSos ?? 1); return AnimatedBuilder( animation: _sosCtrl, - builder: (_, __) => Container( - color: Color.lerp(const Color(0xFFDC2626), - const Color(0xFFFF6B6B), _sosCtrl.value), - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - const Icon(Icons.warning_rounded, - color: Colors.white, size: 18), - const SizedBox(width: 10), - Expanded( - child: Text( - '🚨 SOS AKTIF — User membutuhkan bantuan segera!', - style: GoogleFonts.inter( - color: Colors.white, - fontWeight: FontWeight.w700, - fontSize: 13), + builder: (_, __) { + final pulse = Color.lerp( + const Color(0xFFFFF1F2), + const Color(0xFFFFE4E6), + _sosCtrl.value, + ); + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(18), + child: InkWell( + onTap: _handlingSos ? null : _handleLatestSos, + borderRadius: BorderRadius.circular(18), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: pulse, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFFCA5A5), width: 1.2), + boxShadow: [ + BoxShadow( + color: const Color(0xFFDC2626).withValues(alpha: 0.12), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: const Color(0xFFDC2626), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon(Icons.sos_rounded, + color: Colors.white, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$count SOS aktif', + style: GoogleFonts.inter( + color: const Color(0xFF991B1B), + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + Text( + _handlingSos + ? 'Sedang menandai SOS sebagai ditangani...' + : 'Tap kartu ini atau tombol di bawah untuk menangani.', + style: GoogleFonts.inter( + color: const Color(0xFF7F1D1D), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + IconButton( + onPressed: () { + setState(() => _sosAlert = false); + _sosCtrl.stop(); + }, + icon: const Icon(Icons.close_rounded, + color: Color(0xFF991B1B)), + ), + ], + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _handlingSos ? null : _handleLatestSos, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFDC2626), + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: _handlingSos + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.check_circle_outline_rounded), + label: Text(_handlingSos ? 'Memproses...' : 'Handle SOS'), + ), + ], + ), ), ), - TextButton( - onPressed: _handleLatestSos, - style: TextButton.styleFrom( - foregroundColor: Colors.white), - child: const Text('Handle'), - ), - IconButton( - onPressed: () { - setState(() => _sosAlert = false); - _sosCtrl.stop(); - }, - icon: const Icon(Icons.close, - color: Colors.white, size: 16), - padding: EdgeInsets.zero, - ), - ]), - ), + ); + }, ); } - // ── Greeting row ──────────────────────────────────────────────────────────── Widget _buildGreetingRow() { final hour = DateTime.now().hour; final greeting = hour < 12 @@ -620,8 +684,7 @@ class _GuardianDashboardScreenState extends State ? 'Memantau ${_data?.userName ?? "User"} secara real-time' : 'Belum terhubung dengan User', style: GoogleFonts.inter( - fontSize: 12, - color: const Color(0xFF64748B)), + fontSize: 12, color: const Color(0xFF64748B)), ), ], ), @@ -632,86 +695,109 @@ class _GuardianDashboardScreenState extends State ); } - // ── KPI strip ─────────────────────────────────────────────────────────────── + // ── KPI strip ─────────────────────────────────────────────────────────────── Widget _buildKpiStrip() { if (_data == null) return const SizedBox.shrink(); final d = _data!; - return Row(children: [ - Expanded( - child: _KpiCard( + final cards = [ + _KpiCard( label: 'Status', - value: d.isPaired - ? (d.userOnline ? 'Online' : 'Offline') - : 'Belum Pair', + value: + d.isPaired ? (d.userOnline ? 'Online' : 'Offline') : 'Belum Pair', valueColor: d.isPaired && d.userOnline ? const Color(0xFF16A34A) : const Color(0xFF94A3B8), icon: Icons.person_outline, sub: d.isPaired - ? (d.userOnline ? 'Aktif berjalan' : _formatLastSeen(d.userLastSeen)) + ? (d.userOnline + ? 'Aktif berjalan' + : _formatLastSeen(d.userLastSeen)) : 'Buka menu Pairing', - )), - const SizedBox(width: 10), - Expanded( - child: _KpiCard( + ), + _KpiCard( label: 'Baterai', - value: - d.battery != null ? '${d.battery}%' : '—', + value: d.battery != null ? '${d.battery}%' : '-', valueColor: d.battery != null && d.battery! < 20 ? const Color(0xFFDC2626) : const Color(0xFF0F172A), icon: Icons.battery_std_outlined, sub: d.battery != null - ? (d.battery! > 50 - ? 'Baterai cukup' - : 'Baterai hampir habis') + ? (d.battery! > 50 ? 'Baterai cukup' : 'Baterai rendah') : 'Belum tersedia', - )), - const SizedBox(width: 10), - Expanded( - child: _KpiCard( - label: 'Obstacle Hari Ini', + ), + _KpiCard( + label: 'Obstacle', value: '${d.obstaclesTotal}', valueColor: d.obstaclesTotal > 10 ? const Color(0xFFD97706) : const Color(0xFF0F172A), icon: Icons.radar_outlined, - sub: d.obstaclesTotal > 0 - ? 'AI deteksi aktif' - : 'Belum ada deteksi', - )), - const SizedBox(width: 10), - Expanded( - child: _KpiCard( - label: 'SOS Pending', - value: '${d.unreadSos}', - valueColor: d.unreadSos > 0 - ? const Color(0xFFDC2626) - : const Color(0xFF16A34A), - icon: Icons.sos_outlined, - sub: d.unreadSos > 0 - ? 'Perlu perhatian!' - : 'Aman', - highlight: d.unreadSos > 0, - )), - ]); - } - - // ── Main row: map + user card ──────────────────────────────────────────────── - Widget _buildMainRow() { - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(flex: 3, child: _buildMapCard()), - const SizedBox(width: 12), - SizedBox(width: 180, child: _buildUserCard()), - ], + sub: d.obstaclesTotal > 0 ? 'AI deteksi aktif' : 'Belum ada deteksi', ), + _KpiCard( + label: 'SOS', + value: '${d.unreadSos}', + valueColor: + d.unreadSos > 0 ? const Color(0xFFDC2626) : const Color(0xFF16A34A), + icon: Icons.sos_outlined, + sub: d.unreadSos > 0 ? 'Perlu perhatian' : 'Aman', + highlight: d.unreadSos > 0, + ), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 430) { + return GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 1.9, + children: cards, + ); + } + return Row( + children: [ + for (var i = 0; i < cards.length; i++) ...[ + Expanded(child: cards[i]), + if (i != cards.length - 1) const SizedBox(width: 10), + ], + ], + ); + }, ); } - // ── Map card ───────────────────────────────────────────────────────────────── + // Main row: map + user card ──────────────────────────────────────────────── + Widget _buildMainRow() { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 430) { + return Column( + children: [ + _buildMapCard(), + const SizedBox(height: 12), + SizedBox(height: 250, child: _buildUserCard()), + ], + ); + } + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: 3, child: _buildMapCard()), + const SizedBox(width: 12), + SizedBox(width: 180, child: _buildUserCard()), + ], + ), + ); + }, + ); + } + + // Map card ───────────────────────────────────────────────────────────────── Widget _buildMapCard() { final pos = _liveLatLng ?? (_data?.lastLat != null && _data?.lastLng != null @@ -723,8 +809,7 @@ class _GuardianDashboardScreenState extends State decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), - border: - Border.all(color: const Color(0xFFE2E8F0), width: 1), + border: Border.all(color: const Color(0xFFE2E8F0), width: 1), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), @@ -739,12 +824,10 @@ class _GuardianDashboardScreenState extends State if (pos != null) FlutterMap( mapController: _mapController, - options: MapOptions( - initialCenter: pos, initialZoom: 15), + options: MapOptions(initialCenter: pos, initialZoom: 15), children: [ TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.walkguide.app', ), MarkerLayer(markers: [ @@ -789,16 +872,14 @@ class _GuardianDashboardScreenState extends State mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.map_outlined, - size: 48, - color: const Color(0xFFCBD5E1)), + size: 48, color: const Color(0xFFCBD5E1)), const SizedBox(height: 8), Text( _data?.isPaired == true ? 'Belum ada lokasi terbaru' : 'Pairing dulu untuk melihat peta', style: GoogleFonts.inter( - fontSize: 12, - color: const Color(0xFF94A3B8)), + fontSize: 12, color: const Color(0xFF94A3B8)), ), ], ), @@ -812,8 +893,8 @@ class _GuardianDashboardScreenState extends State right: 10, child: Row(children: [ Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.92), borderRadius: BorderRadius.circular(20), @@ -852,11 +933,10 @@ class _GuardianDashboardScreenState extends State GestureDetector( onTap: () => context.go('/guardian/map'), child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: - const Color(0xFF1A56DB).withValues(alpha: 0.9), + color: const Color(0xFF1A56DB).withValues(alpha: 0.9), borderRadius: BorderRadius.circular(20), ), child: Text('Buka Peta', @@ -875,8 +955,8 @@ class _GuardianDashboardScreenState extends State bottom: 10, left: 10, child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 6), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.92), borderRadius: BorderRadius.circular(8), @@ -900,7 +980,7 @@ class _GuardianDashboardScreenState extends State ); } - // ── User card ───────────────────────────────────────────────────────────────── + // ── User card ───────────────────────────────────────────────────────────────── Widget _buildUserCard() { if (_data?.isPaired != true) { return _buildNoPairingCard(); @@ -926,12 +1006,9 @@ class _GuardianDashboardScreenState extends State Row(children: [ CircleAvatar( radius: 20, - backgroundColor: - const Color(0xFF1A56DB).withValues(alpha: 0.1), + backgroundColor: const Color(0xFF1A56DB).withValues(alpha: 0.1), child: Text( - d.userName.isNotEmpty - ? d.userName[0].toUpperCase() - : 'U', + d.userName.isNotEmpty ? d.userName[0].toUpperCase() : 'U', style: GoogleFonts.outfit( fontSize: 16, color: const Color(0xFF1A56DB), @@ -962,8 +1039,7 @@ class _GuardianDashboardScreenState extends State ), ), const SizedBox(width: 4), - Text( - d.userOnline ? 'Online' : 'Offline', + Text(d.userOnline ? 'Online' : 'Offline', style: GoogleFonts.inter( fontSize: 10, color: d.userOnline @@ -982,7 +1058,7 @@ class _GuardianDashboardScreenState extends State _buildStatRow( Icons.battery_std_outlined, 'Baterai', - d.battery != null ? '${d.battery}%' : '—', + d.battery != null ? '${d.battery}%' : '—', d.battery != null && d.battery! < 20 ? const Color(0xFFDC2626) : const Color(0xFF16A34A)), @@ -992,7 +1068,7 @@ class _GuardianDashboardScreenState extends State 'Kecepatan', d.speed != null ? '${(d.speed! * 3.6).toStringAsFixed(1)} km/h' - : '—', + : '—', const Color(0xFF1A56DB)), const SizedBox(height: 8), _buildStatRow( @@ -1017,7 +1093,7 @@ class _GuardianDashboardScreenState extends State SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: () => context.go('/user/call'), + onPressed: () => context.go('/guardian/call'), icon: const Icon(Icons.call, size: 14), label: const Text('Hubungi User'), style: FilledButton.styleFrom( @@ -1045,9 +1121,7 @@ class _GuardianDashboardScreenState extends State ), Text(value, style: GoogleFonts.outfit( - fontSize: 12, - fontWeight: FontWeight.w700, - color: valueColor)), + fontSize: 12, fontWeight: FontWeight.w700, color: valueColor)), ]); } @@ -1062,8 +1136,7 @@ class _GuardianDashboardScreenState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.link_off, - color: Color(0xFFD97706), size: 40), + const Icon(Icons.link_off, color: Color(0xFFD97706), size: 40), const SizedBox(height: 12), Text('Belum Pairing', style: GoogleFonts.outfit( @@ -1073,8 +1146,8 @@ class _GuardianDashboardScreenState extends State const SizedBox(height: 6), Text( 'Masukkan Unique ID User untuk mulai memantau.', - style: GoogleFonts.inter( - fontSize: 11, color: const Color(0xFF92400E)), + style: + GoogleFonts.inter(fontSize: 11, color: const Color(0xFF92400E)), textAlign: TextAlign.center, ), const SizedBox(height: 14), @@ -1087,8 +1160,7 @@ class _GuardianDashboardScreenState extends State padding: const EdgeInsets.symmetric(vertical: 8)), child: Text('Pair Sekarang', style: GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w600)), + fontSize: 12, fontWeight: FontWeight.w600)), ), ), ], @@ -1096,7 +1168,7 @@ class _GuardianDashboardScreenState extends State ); } - // ── Activity section ───────────────────────────────────────────────────────── + // ── Activity section ───────────────────────────────────────────────────────── Widget _buildActivitySection() { final items = _data?.recentActivity ?? const []; return Container( @@ -1115,8 +1187,7 @@ class _GuardianDashboardScreenState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: - const EdgeInsets.fromLTRB(16, 14, 12, 10), + padding: const EdgeInsets.fromLTRB(16, 14, 12, 10), child: Row(children: [ Text('Aktivitas Terkini', style: GoogleFonts.outfit( @@ -1127,12 +1198,11 @@ class _GuardianDashboardScreenState extends State TextButton.icon( onPressed: () => context.go('/guardian/logs'), icon: const Icon(Icons.open_in_new, size: 13), - label: Text('Semua', - style: GoogleFonts.inter(fontSize: 12)), + label: Text('Semua', style: GoogleFonts.inter(fontSize: 12)), style: TextButton.styleFrom( foregroundColor: const Color(0xFF1A56DB), - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 4)), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4)), ), ]), ), @@ -1145,8 +1215,7 @@ class _GuardianDashboardScreenState extends State ? 'Belum ada aktivitas. Minta User start WalkGuide.' : 'Aktivitas akan muncul setelah pairing.', style: GoogleFonts.inter( - fontSize: 12, - color: const Color(0xFF94A3B8)), + fontSize: 12, color: const Color(0xFF94A3B8)), textAlign: TextAlign.center, ), ), @@ -1156,9 +1225,7 @@ class _GuardianDashboardScreenState extends State final i = entry.key; final item = entry.value; return Column(children: [ - if (i == 0) - const Divider( - height: 1, color: Color(0xFFF1F5F9)), + if (i == 0) const Divider(height: 1, color: Color(0xFFF1F5F9)), _ActivityTile(data: item), ]); }), @@ -1168,7 +1235,7 @@ class _GuardianDashboardScreenState extends State ); } - // ── Quick actions ───────────────────────────────────────────────────────────── + // ── Quick actions ───────────────────────────────────────────────────────────── Widget _buildQuickActions() { final actions = [ _ActionItem( @@ -1226,52 +1293,50 @@ class _GuardianDashboardScreenState extends State fontWeight: FontWeight.w700, color: const Color(0xFF0F172A))), ), - GridView.count( - crossAxisCount: 3, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 1.4, - children: actions - .map((a) => _QuickActionCard(item: a)) - .toList(), - ), + LayoutBuilder(builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 430; + return GridView.count( + crossAxisCount: isNarrow ? 2 : 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: isNarrow ? 1.55 : 1.15, + children: actions.map((a) => _QuickActionCard(item: a)).toList(), + ); + }), ], ); } - // ── Loading skeleton ────────────────────────────────────────────────────────── + // ── Loading skeleton ────────────────────────────────────────────────────────── Widget _buildSkeleton() { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column(children: [ - _SkeletonBox(height: 32, width: double.infinity, - borderRadius: 8), + _SkeletonBox(height: 32, width: double.infinity, borderRadius: 8), const SizedBox(height: 14), - Row(children: List.generate( - 4, - (i) => Expanded( - child: Padding( - padding: EdgeInsets.only( - right: i < 3 ? 10 : 0), - child: _SkeletonBox( - height: 80, - width: double.infinity, - borderRadius: 12), - ), - ))), + Row( + children: List.generate( + 4, + (i) => Expanded( + child: Padding( + padding: EdgeInsets.only(right: i < 3 ? 10 : 0), + child: _SkeletonBox( + height: 80, + width: double.infinity, + borderRadius: 12), + ), + ))), const SizedBox(height: 14), - _SkeletonBox( - height: 220, width: double.infinity, borderRadius: 16), + _SkeletonBox(height: 220, width: double.infinity, borderRadius: 16), const SizedBox(height: 14), - _SkeletonBox( - height: 160, width: double.infinity, borderRadius: 16), + _SkeletonBox(height: 160, width: double.infinity, borderRadius: 16), ]), ); } - // ── Error state ─────────────────────────────────────────────────────────────── + // ── Error state ─────────────────────────────────────────────────────────────── Widget _buildError() { return Center( child: Padding( @@ -1307,9 +1372,9 @@ class _GuardianDashboardScreenState extends State } } -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // DATA MODELS -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── class _DashboardData { final String userName; @@ -1376,9 +1441,9 @@ class _ActionItem { }); } -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // SUB-WIDGETS -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── class _KpiCard extends StatelessWidget { final String label; @@ -1402,18 +1467,14 @@ class _KpiCard extends StatelessWidget { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: - highlight ? const Color(0xFFFFF1F2) : Colors.white, + color: highlight ? const Color(0xFFFFF1F2) : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all( - color: highlight - ? const Color(0xFFFECACA) - : const Color(0xFFE2E8F0), + color: + highlight ? const Color(0xFFFECACA) : const Color(0xFFE2E8F0), width: 1), boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.03), - blurRadius: 8), + BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8), ], ), child: Column( @@ -1432,11 +1493,16 @@ class _KpiCard extends StatelessWidget { ), ]), const SizedBox(height: 6), - Text(value, - style: GoogleFonts.outfit( - fontSize: 20, - fontWeight: FontWeight.w700, - color: valueColor)), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text(value, + maxLines: 1, + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w700, + color: valueColor)), + ), const SizedBox(height: 2), Text(sub, style: GoogleFonts.inter( @@ -1460,17 +1526,13 @@ class _ActivityTile extends StatelessWidget { final logType = data['logType']?.toString() ?? ''; final cfg = _activityConfig(logType); final created = - DateTime.tryParse(data['createdAt']?.toString() ?? '') - ?.toLocal(); - final timeStr = created == null - ? '' - : '${_two(created.hour)}:${_two(created.minute)}'; - final desc = - data['description']?.toString() ?? logType; + DateTime.tryParse(data['createdAt']?.toString() ?? '')?.toLocal(); + final timeStr = + created == null ? '' : '${_two(created.hour)}:${_two(created.minute)}'; + final desc = data['description']?.toString() ?? logType; return Padding( - padding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Row(children: [ Container( width: 32, @@ -1496,8 +1558,7 @@ class _ActivityTile extends StatelessWidget { Text( desc, style: GoogleFonts.inter( - fontSize: 11, - color: const Color(0xFF64748B)), + fontSize: 11, color: const Color(0xFF64748B)), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -1529,8 +1590,7 @@ class _QuickActionCard extends StatelessWidget { padding: const EdgeInsets.all(10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), - border: - Border.all(color: const Color(0xFFE2E8F0)), + border: Border.all(color: const Color(0xFFE2E8F0)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1543,8 +1603,7 @@ class _QuickActionCard extends StatelessWidget { color: item.color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(7), ), - child: Icon(item.icon, - size: 15, color: item.color), + child: Icon(item.icon, size: 15, color: item.color), ), const SizedBox(height: 6), Text(item.label, @@ -1556,8 +1615,7 @@ class _QuickActionCard extends StatelessWidget { overflow: TextOverflow.ellipsis), Text(item.sub, style: GoogleFonts.inter( - fontSize: 10, - color: const Color(0xFF94A3B8)), + fontSize: 10, color: const Color(0xFF94A3B8)), maxLines: 1, overflow: TextOverflow.ellipsis), ], @@ -1575,8 +1633,7 @@ class _SosBadge extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: const Color(0xFFDC2626), borderRadius: BorderRadius.circular(20), @@ -1588,8 +1645,7 @@ class _SosBadge extends StatelessWidget { ], ), child: Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.warning_rounded, - color: Colors.white, size: 13), + const Icon(Icons.warning_rounded, color: Colors.white, size: 13), const SizedBox(width: 4), Text('$count SOS', style: GoogleFonts.inter( @@ -1606,9 +1662,7 @@ class _SkeletonBox extends StatefulWidget { final double width; final double borderRadius; const _SkeletonBox( - {required this.height, - required this.width, - required this.borderRadius}); + {required this.height, required this.width, required this.borderRadius}); @override State<_SkeletonBox> createState() => _SkeletonBoxState(); @@ -1635,19 +1689,18 @@ class _SkeletonBoxState extends State<_SkeletonBox> height: widget.height, width: widget.width, decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(widget.borderRadius), - color: Color.lerp(const Color(0xFFE2E8F0), - const Color(0xFFF8FAFC), _ctrl.value), + borderRadius: BorderRadius.circular(widget.borderRadius), + color: Color.lerp( + const Color(0xFFE2E8F0), const Color(0xFFF8FAFC), _ctrl.value), ), ), ); } } -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── // HELPERS -// ───────────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── class _ActivityCfg { final IconData icon; @@ -1665,8 +1718,8 @@ _ActivityCfg _activityConfig(String logType) { return const _ActivityCfg( Icons.stop_circle_outlined, Color(0xFF64748B), 'WalkGuide Berhenti'); case 'OBSTACLE_DETECTED': - return const _ActivityCfg( - Icons.warning_amber_outlined, Color(0xFFD97706), 'Obstacle Terdeteksi'); + return const _ActivityCfg(Icons.warning_amber_outlined, Color(0xFFD97706), + 'Obstacle Terdeteksi'); case 'SOS_TRIGGERED': return const _ActivityCfg( Icons.sos_outlined, Color(0xFFDC2626), 'SOS Dikirim'); diff --git a/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart b/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart index ed14527..480fc75 100644 --- a/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart +++ b/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart @@ -345,25 +345,58 @@ class _PairingStatusCardState extends State<_PairingStatusCard> { @override Widget build(BuildContext context) { 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( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(18), decoration: BoxDecoration( - color: _active ? const Color(0xFFF0FDF4) : const Color(0xFFFFFBEB), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: _active ? const Color(0xFFBBF7D0) : const Color(0xFFFDE68A)), + color: cardColor, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: accent.withValues(alpha: 0.28)), + boxShadow: [ + BoxShadow( + color: accent.withValues(alpha: 0.10), + blurRadius: 24, + offset: const Offset(0, 12), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ - Icon(_active ? Icons.link : Icons.info_outline, - color: _active - ? const Color(0xFF16A34A) - : const Color(0xFFD97706)), + Container( + width: 42, + height: 42, + 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), - Expanded(child: Text(_status)), + Expanded( + child: Text(_status, + style: const TextStyle( + color: Color(0xFF0F172A), + fontWeight: FontWeight.w700, + height: 1.25)), + ), IconButton( onPressed: _loading ? null : _load, icon: _loading @@ -427,33 +460,84 @@ class _Page extends StatelessWidget { @override Widget build(BuildContext context) { return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)], + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TweenAnimationBuilder( + 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: [ - Text(title, - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.w800)), - if (subtitle != null) - Text(subtitle!, - style: const TextStyle(color: Color(0xFF64748B))), + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: + const Color(0xFF38BDF8).withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(16), + 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), - Expanded(child: child), - ], + ), + const SizedBox(height: 16), + Expanded(child: child), + ], + ), ), ), ); @@ -474,24 +558,47 @@ class _InfoCard extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(18), decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(12)), + color: Colors.white, + 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( children: [ - Icon(icon, color: const Color(0xFF1A56DB)), - const SizedBox(width: 12), + Container( + 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( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title), + Text(title, + style: const TextStyle( + color: Color(0xFF64748B), fontWeight: FontWeight.w700)), SelectableText(value, 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) ...[ - const SizedBox(height: 4), + const SizedBox(height: 6), Text(helper!, style: const TextStyle( color: Color(0xFF64748B), fontSize: 12)), diff --git a/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart b/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart index 0fb610d..7a72ed9 100644 --- a/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart +++ b/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart @@ -9,16 +9,6 @@ import '../../core/constants/app_constants.dart'; import '../../core/errors/friendly_error.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 { const ServerConnectScreen({super.key}); @@ -27,11 +17,17 @@ class ServerConnectScreen extends StatefulWidget { } class _ServerConnectScreenState extends State { - final _url = TextEditingController(); + final _url = TextEditingController(text: 'http://127.0.0.1:8080'); bool _loading = false; bool _ok = false; String? _message; + @override + void dispose() { + _url.dispose(); + super.dispose(); + } + Future _test() async { setState(() { _loading = true; @@ -47,8 +43,8 @@ class _ServerConnectScreenState extends State { )).get('$clean/api/v1/auth/ping'); _ok = res.statusCode == 200 && res.data['success'] == true; _message = _ok - ? 'Server aktif dan siap dipakai.' - : 'Server merespons dengan format tidak valid.'; + ? 'Server aktif. WalkGuide siap tersambung.' + : 'Server merespons, tetapi format ping tidak valid.'; }, onError: (message) => _message = message, fallback: 'Tidak bisa terhubung. Periksa URL dan jaringan.', @@ -63,49 +59,219 @@ class _ServerConnectScreenState extends State { if (mounted) context.go('/splash'); } + void _useUsbUrl() => setState(() => _url.text = 'http://127.0.0.1:8080'); + @override Widget build(BuildContext context) { - return _AuthFrame( - title: 'Connect to Server', - subtitle: 'Masukkan URL backend WalkGuide yang diberikan dosen.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + return Scaffold( + backgroundColor: const Color(0xFFF5F8FC), + body: Stack( children: [ - TextField( - controller: _url, - keyboardType: TextInputType.url, - decoration: const InputDecoration( - labelText: 'Server URL', - hintText: 'http://server-ip:8080', - prefixIcon: Icon(Icons.dns_outlined), - )), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _loading ? null : _test, - icon: _loading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.wifi_tethering), - label: const Text('Test Connection'), + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF071226), + Color(0xFF123D6B), + Color(0xFFF7FAFC) + ], + stops: [0, 0.42, 1], + ), + ), + ), ), - 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), - label: const Text('Continue')), - ], - const SizedBox(height: 24), - const Center( - child: Text( - 'v1.0.0 | For Testing Purposes Only', - style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + const Positioned( + top: -80, + right: -70, + child: _GlowBlob(size: 250, color: Color(0xFF38BDF8)), + ), + const Positioned( + bottom: -90, + left: -80, + child: _GlowBlob(size: 260, color: Color(0xFF22C55E)), + ), + SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 390; + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB(20, compact ? 20 : 36, 20, 24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: TweenAnimationBuilder( + 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 { } } -// --------------------------------------------------------------------------- -// Shared private widgets -// --------------------------------------------------------------------------- - -class _AuthFrame extends StatelessWidget { - final String title; - final String subtitle; - final Widget child; - const _AuthFrame( - {required this.title, required this.subtitle, required this.child}); +class _GlowBlob extends StatelessWidget { + final double size; + final Color color; + const _GlowBlob({required this.size, required this.color}); @override Widget build(BuildContext context) { - return Scaffold( - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460), - child: Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - side: const BorderSide(color: Color(0xFFE2E8F0))), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon(Icons.navigation_rounded, - color: Color(0xFF1A56DB), size: 42), - const SizedBox(height: 14), - Text(title, - textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.w800)), - const SizedBox(height: 4), - Text(subtitle, - textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFF64748B))), - const SizedBox(height: 22), - child, - ], - ), + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color.withValues(alpha: 0.18), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.22), + blurRadius: 60, + spreadRadius: 8), + ], + ), + ); + } +} + +class _HintChip extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback? onTap; + const _HintChip({required this.icon, required this.label, this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + 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 Widget build(BuildContext context) { - return DecoratedBox( + final color = success ? const Color(0xFF16A34A) : const Color(0xFFDC2626); + return Container( + padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2), - borderRadius: BorderRadius.circular(10), + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.22)), ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon( - success ? Icons.check_circle_outline : Icons.error_outline, - color: - success ? const Color(0xFF166534) : const Color(0xFF991B1B), - size: 20, - ), - const SizedBox(width: 8), - Expanded( + child: Row( + children: [ + Icon(success ? Icons.check_circle_outline : Icons.error_outline, + color: color, size: 20), + const SizedBox(width: 8), + Expanded( child: Text(message, - style: TextStyle( - color: success - ? const Color(0xFF166534) - : const Color(0xFF991B1B))), - ), - ], - ), + style: TextStyle(color: color, fontWeight: FontWeight.w700))), + ], ), ); } diff --git a/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart b/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart index 0042155..9b5fe7f 100644 --- a/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart @@ -7,6 +7,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:go_router/go_router.dart'; import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; @@ -139,6 +140,8 @@ class _SosScreenState extends State Future _confirmAndSend() async { if (_sosCubit.state.phase == SosPhase.sending) return; + final paired = await _ensurePaired(); + if (!paired) return; // Confirmation dialog — prevents accidental tap final confirm = await showDialog( @@ -181,6 +184,35 @@ class _SosScreenState extends State await _sendSos(); } + Future _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().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 _sendSos() async { await runFriendlyAction( () async { @@ -217,96 +249,98 @@ class _SosScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Header - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'SOS', - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.w800), - ), - const Text( - 'Emergency alert ke Guardian', - 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, + // Header + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SOS', + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.w800), + ), + const Text( + 'Emergency alert ke Guardian', + 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, + ), + ), + ), + + 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, - )), - ], ), - ), - ); + ); }, ); } diff --git a/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart b/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart index 7d7c931..70aea8d 100644 --- a/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart @@ -12,6 +12,7 @@ import '../../app/injection_container.dart'; import '../../core/ai/detection_export.dart'; import '../../core/ai/obstacle_alert_strategy.dart'; import '../../core/errors/friendly_error.dart'; +import '../../core/network/api_client.dart'; import '../../core/services/location_reporter_service.dart'; import '../../core/services/tts_service.dart'; import 'application/walk_guide_cubit.dart'; @@ -27,10 +28,15 @@ class WalkGuideScreen extends StatefulWidget { State createState() => _WalkGuideScreenState(); } -class _WalkGuideScreenState extends State { +class _WalkGuideScreenState extends State + with SingleTickerProviderStateMixin { late final WalkGuideCubit _cubit; + late final AnimationController _scanCtrl; CameraController? _camera; bool _processingFrame = false; + bool _pairingLoading = true; + bool _paired = false; + String? _pairedName; DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0); @@ -39,6 +45,11 @@ class _WalkGuideScreenState extends State { void initState() { super.initState(); _cubit = sl(); + _scanCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2200), + )..repeat(); + _loadPairingStatus(); } @override @@ -49,6 +60,7 @@ class _WalkGuideScreenState extends State { } _camera?.dispose(); sl().stop(); + _scanCtrl.dispose(); _cubit.close(); super.dispose(); } @@ -56,6 +68,8 @@ class _WalkGuideScreenState extends State { Future _toggle() async { final next = !_cubit.state.active; if (next) { + final paired = await _ensurePaired(); + if (!paired) return; await _startCamera(); await sl().start(walkGuideActive: true); await _cubit.start(); @@ -69,6 +83,48 @@ class _WalkGuideScreenState extends State { sl().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan'); } + Future _loadPairingStatus() async { + await runFriendlyAction( + () async { + final res = await sl() + .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 _ensurePaired() async { + if (_paired) return true; + await _loadPairingStatus(); + if (_paired) return true; + if (!mounted) return false; + sl().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() { final detector = sl(); if (kIsWeb) { @@ -86,33 +142,33 @@ class _WalkGuideScreenState extends State { if (_camera != null) return; await runFriendlyAction( () async { - final cameras = await availableCameras(); - if (cameras.isEmpty) return; - final backCamera = cameras.firstWhere( - (camera) => camera.lensDirection == CameraLensDirection.back, - orElse: () => cameras.first, - ); - final controller = CameraController( - backCamera, - ResolutionPreset.medium, - enableAudio: false, - imageFormatGroup: ImageFormatGroup.yuv420, - ); - await controller.initialize(); - if (!mounted) { - await controller.dispose(); - return; - } - await runFriendlyAction( - () => controller.startImageStream(_onCameraImage), - onError: (_) { - _cubit.updateStatus(kIsWeb - ? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.' - : 'Camera preview aktif, tapi image stream belum tersedia.'); - }, - fallback: 'Camera preview aktif, tapi image stream belum tersedia.', - ); - setState(() => _camera = controller); + final cameras = await availableCameras(); + if (cameras.isEmpty) return; + final backCamera = cameras.firstWhere( + (camera) => camera.lensDirection == CameraLensDirection.back, + orElse: () => cameras.first, + ); + final controller = CameraController( + backCamera, + ResolutionPreset.medium, + enableAudio: false, + imageFormatGroup: ImageFormatGroup.yuv420, + ); + await controller.initialize(); + if (!mounted) { + await controller.dispose(); + return; + } + await runFriendlyAction( + () => controller.startImageStream(_onCameraImage), + onError: (_) { + _cubit.updateStatus(kIsWeb + ? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.' + : 'Camera preview aktif, tapi image stream belum tersedia.'); + }, + fallback: 'Camera preview aktif, tapi image stream belum tersedia.', + ); + setState(() => _camera = controller); }, onError: (_) => _cubit.updateStatus('Camera unavailable.'), fallback: 'Camera unavailable.', @@ -190,7 +246,9 @@ class _WalkGuideScreenState extends State { bloc: _cubit, builder: (context, state) => _Page( title: 'WalkGuide', - subtitle: 'On-device AI detection surface', + subtitle: _paired + ? 'Connected to ${_pairedName ?? 'Guardian'}' + : 'Pair with Guardian to unlock live protection', actions: [ IconButton( onPressed: () => context.go('/user/benchmark'), @@ -202,64 +260,52 @@ class _WalkGuideScreenState extends State { child: Column( children: [ Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(16)), - child: Stack( - children: [ - 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))), - ], - ), + child: _VisionPanel( + state: state, + camera: _camera, + scanCtrl: _scanCtrl, + paired: _paired, + pairingLoading: _pairingLoading, + onPairingTap: () => context.go('/user/pairing'), ), ), const SizedBox(height: 14), + _StatusStrip( + active: state.active, + paired: _paired, + latestDetection: state.latestDetection, + ), + const SizedBox(height: 12), Row( children: [ Expanded( - child: FilledButton.icon( - onPressed: _toggle, - icon: - Icon(state.active ? Icons.stop : Icons.play_arrow), - label: Text(state.active ? 'Stop' : 'Start'))), + flex: 2, + child: FilledButton.icon( + onPressed: _pairingLoading ? null : _toggle, + icon: Icon(state.active ? Icons.stop : Icons.play_arrow), + 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 { } } +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 { final DetectionResult detection; const _DetectionOverlayPainter(this.detection); @@ -356,34 +809,67 @@ class _Page extends StatelessWidget { @override Widget build(BuildContext context) { return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.w800)), - if (subtitle != null) - Text(subtitle!, - style: const TextStyle(color: Color(0xFF64748B))), - ], + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)], + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 46, + height: 46, + 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), ), - ), - ...?actions, - ], - ), - const SizedBox(height: 16), - Expanded(child: child), - ], + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + ], + ), ), ), ); diff --git a/walkguide-mobile/walkguide_app/lib/main.dart b/walkguide-mobile/walkguide_app/lib/main.dart index 0777bbe..6703c4c 100644 --- a/walkguide-mobile/walkguide_app/lib/main.dart +++ b/walkguide-mobile/walkguide_app/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:camera/camera.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'app/injection_container.dart'; import 'app/app.dart'; @@ -8,6 +9,11 @@ import 'core/utils/init_guard.dart'; List cameras = []; +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + await Firebase.initializeApp(); +} + Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -18,7 +24,9 @@ Future main() async { []; if (!kIsWeb) { - await ignoreInitFailure(() => Firebase.initializeApp(), label: 'Firebase init'); + await ignoreInitFailure(() => Firebase.initializeApp(), + label: 'Firebase init'); + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); } // Init GetIt dependencies diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart index 9cb0c7f..8948efc 100644 --- a/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart @@ -10,6 +10,7 @@ import '../../core/services/hardware_shortcut_listener.dart'; import '../../core/services/stt_service.dart'; import '../../core/services/tts_service.dart'; import '../../core/services/voice_command_handler.dart'; +import '../../core/theme/app_colors.dart'; class UserShell extends StatefulWidget { final Widget child; @@ -75,7 +76,8 @@ class _UserShellState extends State { if (data is! List) return; final commands = data .whereType() - .map((item) => _voiceCommandFromJson(Map.from(item))) + .map((item) => + _voiceCommandFromJson(Map.from(item))) .whereType() .toList(); if (commands.isNotEmpty) { @@ -181,18 +183,40 @@ class _AppShell extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: child, - bottomNavigationBar: 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, + backgroundColor: AppColors.surface, + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: KeyedSubtree( + key: ValueKey(location), + child: child, + ), + ), + 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, + ), + ], + ), ), ); } diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart index 9b749d6..26731fb 100644 --- a/walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/theme/app_colors.dart'; + class FeaturePage extends StatelessWidget { final String title; final String subtitle; @@ -18,33 +20,51 @@ class FeaturePage extends StatelessWidget { Widget build(BuildContext context) { return SafeArea( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: - Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w900, - color: const Color(0xFF0F172A), - ), - ), - Text( - subtitle, - style: const TextStyle(color: Color(0xFF64748B)), - ), - ], - ), + TweenAnimationBuilder( + tween: Tween(begin: 12, end: 0), + duration: const Duration(milliseconds: 360), + curve: Curves.easeOutCubic, + builder: (_, offset, child) => Opacity( + opacity: (1 - offset / 12).clamp(0.0, 1.0), + child: Transform.translate( + offset: Offset(0, offset), + child: child, ), - 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), Expanded(child: child), @@ -77,7 +97,16 @@ class FeatureEmptyPanel extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, 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), Text( title, @@ -88,7 +117,7 @@ class FeatureEmptyPanel extends StatelessWidget { Text( message, textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFF64748B), height: 1.35), + style: const TextStyle(color: AppColors.muted, height: 1.35), ), if (action != null) ...[ const SizedBox(height: 16), @@ -120,7 +149,7 @@ class FeatureErrorPanel extends StatelessWidget { padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: const Color(0xFFFEF2F2), - borderRadius: BorderRadius.circular(18), + borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFFFECACA)), ), child: Column( diff --git a/walkguide-mobile/walkguide_app/walkguide_now.png b/walkguide-mobile/walkguide_app/walkguide_now.png new file mode 100644 index 0000000..da0701d Binary files /dev/null and b/walkguide-mobile/walkguide_app/walkguide_now.png differ diff --git a/walkguide-mobile/walkguide_app/web/index.html b/walkguide-mobile/walkguide_app/web/index.html index 5dd2001..706b825 100644 --- a/walkguide-mobile/walkguide_app/web/index.html +++ b/walkguide-mobile/walkguide_app/web/index.html @@ -33,6 +33,7 @@ +