diff --git a/.agent/task-state.json b/.agent/task-state.json new file mode 100644 index 0000000..ec1b1ab --- /dev/null +++ b/.agent/task-state.json @@ -0,0 +1,9 @@ +{ + "28": { + "issue": 28, + "branch": "feat/28-eva-mobile-client", + "state": "In Progress", + "updated_at": 1793875471, + "evidence_summary": "Evidence accepted: active task branch verified." + } +} \ No newline at end of file diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index a0a5e0e..0f2cfc4 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -32,9 +32,11 @@ - **Sweet-Spot Cohesion & Modularity**: - Keep files cohesive; extract independent timers, custom painters, and multi-step modals into dedicated widget files while keeping local layout together. - See [.agents/rules/cohesion-and-modularity.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/cohesion-and-modularity.md) for natural separation criteria. +- **Component Reuse & Single Source of Truth**: + - Before creating new UI elements or duplicate styling, always verify and reuse existing widgets in `apps/eva_mobile/lib/widgets/` (e.g. `AppBackButton`, `GlassCard`, `AppSnackBar`) or feature widgets. Never declare manual inline back buttons across screens. - **Hierarchical Workspace Scopes**: - Flutter Mobile: Follow [apps/eva_mobile/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/eva_mobile/AGENTS.md) (Clean Architecture, Riverpod DI, Pure Dart models). - - Backend API: Follow [apps/backend/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/backend/AGENTS.md) (FastAPI routes, Pydantic schemas, Alembic migrations, FastMCP). + - Backend API: Follow [apps/backend/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/backend/AGENTS.md) and [.agents/rules/backend-clean-architecture.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/backend-clean-architecture.md) (Thin routes 3-5 lines, modular services/schemas, centralized DI, Alembic migrations). - AI & Orchestration: Follow [packages/ai/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/packages/ai/AGENTS.md) (Abstract Base Classes, LLM/TTS factories, graceful fallbacks). - **Available Skills**: - `monorepo-ops`: Docker dev/prod, Alembic migrations, database resets, and backend service runner. @@ -61,6 +63,7 @@ ## 5. Supervised Autonomous Development Coordination Policies - **Human-Owned Tasks**: Every task has a single human developer owner. AI agents code only on the task selected by the developer and must never auto-claim or auto-assign tasks. +- **Issue Mutation Review Gate**: The AI agent must never create, edit, close, or delete GitHub Issues autonomously. All additions, updates, or deletions must be presented to the developer (via draft/dry-run) and receive explicit review and approval before execution. - **Identity Preflight Mandatory**: Before modifying code or updating task status, verify local identity via `python3 scripts/agentic/doctor.py`. If identity is missing, prompt developer to run `python3 scripts/agentic/bootstrap.py`. - **Session Context Reconciliation**: At the beginning of a session, inspect active context via `python3 scripts/agentic/tasks.py` to reconcile local and remote state. - **Strict Branch & Worktree Naming**: All branches must follow `{type}/{issue-number}-{short-kebab-slug}` where type is one of `feat`, `fix`, `refactor`, `docs`, `test`, `chore`. Personal branches (`dev/{name}`) are prohibited. diff --git a/.agents/rules/abstraction-and-mock-prod.md b/.agents/rules/abstraction-and-mock-prod.md index f64562d..aadf3c5 100644 --- a/.agents/rules/abstraction-and-mock-prod.md +++ b/.agents/rules/abstraction-and-mock-prod.md @@ -122,17 +122,18 @@ class LLMFactory: ``` ### 2.3 Dependency Injection di FastAPI Endpoints -Endpoint di `apps/backend/api/app/api/routes` **DILARANG KERAS** menginisialisasi provider atau service konkret secara manual di dalam handler fungsi. Gunakan `Depends()`: +Endpoint di `apps/backend/api/app/api/routes` **DILARANG KERAS** menginisialisasi provider atau service konkret secara manual di dalam handler fungsi, dan dilarang mengorkestrasi provider AI/DB langsung di dalam router. Router wajib menginjeksi Domain Service via `Depends()`: ```python -# ✅ BENAR: Menggunakan Dependency Injection -@router.post("/chat/completions") +# ✅ BENAR: Menggunakan Domain Service Injection & Pydantic response_model +@router.post("/chat/completions", response_model=ChatCompletionResponse) async def chat_completion( payload: ChatRequest, - llm: BaseLLMProvider = Depends(get_llm_provider), - db: AsyncSession = Depends(get_db_session) -): - return await llm.generate_response(payload.message) + service: ConversationService = Depends(get_conversation_service), +) -> ChatCompletionResponse: + """Delegasikan prompt generation dan DB logging ke service domain.""" + return await service.generate_chat_reply(payload) ``` +> **Catatan Arsitektur:** Provider konkret (`BaseLLMProvider`) diinjeksi ke dalam `ConversationService` melalui factory di `app/api/dependencies.py`, bukan di dalam router handler. --- diff --git a/.agents/rules/agentic-workflow-constraints.md b/.agents/rules/agentic-workflow-constraints.md index 79b9078..b34a5bb 100644 --- a/.agents/rules/agentic-workflow-constraints.md +++ b/.agents/rules/agentic-workflow-constraints.md @@ -13,6 +13,7 @@ This document establishes non-negotiable architectural boundaries and safety con - **Human Accountability**: Every task has a single human developer owner. The AI agent acts as a supervised pair programmer, not an unconstrained autonomous actor. - **No Unsolicited Work**: The agent must never initiate unassigned tasks, switch tasks, or create speculative branches without explicit human developer instruction. +- **Issue Mutation Review Gate**: The agent must never autonomously create, edit, close, or delete GitHub Issues without presenting the proposal (draft/dry-run) to the developer and obtaining explicit review and approval first. - **Crash Invariant**: If the agent process terminates or crashes, **no automated state transition or recovery occurs**. The task remains in its last verified state until the developer resumes the session. - **No Autonomous Handoff (Phase 1)**: Cross-developer delegation, auto-claim bots, and autonomous cross-agent orchestrators are strictly out of scope. diff --git a/.agents/rules/backend-clean-architecture.md b/.agents/rules/backend-clean-architecture.md new file mode 100644 index 0000000..034723a --- /dev/null +++ b/.agents/rules/backend-clean-architecture.md @@ -0,0 +1,171 @@ +--- +trigger: model_decision +description: Aturan dan standar Clean Architecture, Thin Routers (3-5 baris), modular services (app/services/{module}/), dan schemas (app/schemas/{module}/) untuk backend FastAPI di apps/backend. +--- + +# FastAPI Clean Architecture & Modular Backend Standards + +> **Scope:** `apps/backend/api/**`, `apps/backend/workers/**`, `apps/backend/mcp/**` +> **Tujuan:** Menjamin kode backend selalu menerapkan prinsip *Clean Architecture*, *Single Responsibility Principle* (SRP), *Dependency Injection* (DI), dan pemisahan modular berbasis domain agar setiap route handler sangat bersih (3–5 baris) dan tidak ada lagi *fat controllers* atau duplikasi kode. + +--- + +## 1. Struktur Organisasi Modul (`services/` & `schemas/`) + +Seluruh domain bisnis di `apps/backend/api/app` **wajib** dikelompokkan ke dalam paket folder modular: + +```text +apps/backend/api/app/ +├── api/ +│ ├── dependencies.py # Centralized DI (get_current_user, get_db, service factories) +│ └── routes/ # Thin Route Handlers (3-5 baris per endpoint) +│ ├── auth.py +│ ├── user.py +│ ├── diaries.py +│ ├── recommendations.py +│ ├── conversations.py +│ ├── call.py +│ ├── dass.py +│ ├── analytics.py +│ └── health.py +├── schemas/ # Strict Pydantic Contracts per Modul +│ └── {module}/ +│ ├── __init__.py # Barrel export seluruh model request & response +│ ├── requests.py # Request body & query parameters +│ └── responses.py # Response models (DTO) +├── services/ # Domain Logic & Database Orchestration per Modul +│ └── {module}/ +│ ├── __init__.py # Barrel export service classes +│ ├── {name}_service.py # Implementasi domain service +│ └── ... # Sub-komponen pendukung jika modul berat (heavyweight) +└── models/ # SQLAlchemy Entities (Pure data models & relationships) +``` + +### Aturan Paket & Barrel Export (`__init__.py`): +1. Setiap subfolder di `app/schemas/{module}/` **wajib** memiliki `__init__.py` yang me-re-export semua class request dan response publik. +2. Setiap subfolder di `app/services/{module}/` **wajib** memiliki `__init__.py` yang me-re-export class service publik. +3. Import di level router harus bersih dan ringkas: + ```python + from app.schemas.auth import LoginRequest, TokenResponse + from app.services.auth import AuthService + ``` + +--- + +## 2. Invariant Thin Router (Maksimal 3–5 Baris per Endpoint) + +Router FastAPI (`app/api/routes/*.py`) bertindak murni sebagai **HTTP Transport Controller**. + +### ❌ Dilarang Keras di Router Handler: +1. **Dilarang Inline SQL**: Tidak boleh ada query SQLAlchemy (`select()`, `db.execute()`, `db.commit()`, `db.add()`) di dalam router. Seluruh interaksi database wajib di dalam class service. +2. **Dilarang Inline Business Logic / Calculations**: Kalkulasi persentase emosi, DASS scoring, pemrosesan teks, dan validasi krisis dilarang ditulis di router. +3. **Dilarang Raw Dictionary Returns**: Dilarang mengembalikan `dict[str, Any]` tanpa `response_model`. Seluruh endpoint HTTP REST wajib mendeklarasikan `response_model=...` menggunakan Pydantic schema dari `app/schemas/{module}/` (Kecuali streaming SSE yang mengembalikan `StreamingResponse` dan endpoint `@router.websocket`). +4. **Dilarang Menduplikasi `_get_current_user`**: Dilarang membuat fungsi privat `_get_current_user` di dalam file router. Seluruh autentikasi wajib mengimpor `get_current_user` dari `app.api.dependencies`. +5. **Dilarang Instansiasi Manual**: Dilarang memanggil `AsyncSessionLocal()`, `LLMFactory.get_provider()`, atau class service secara manual di dalam router. Gunakan FastAPI `Depends()`. + +### ✅ Contoh Standar Thin Route (3–5 Baris): + +```python +@router.post("/login", response_model=TokenResponse) +async def login( + payload: LoginRequest, + service: AuthService = Depends(get_auth_service), +) -> TokenResponse: + """Otentikasi pengguna dan terbitkan akses token.""" + return await service.login(payload) + +@router.post("", response_model=DiaryEntryResponse, status_code=status.HTTP_201_CREATED) +async def create_diary( + payload: CreateDiaryRequest, + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> DiaryEntryResponse: + """Simpan refleksi harian pengguna.""" + return await service.create_entry(user_id=user.id, payload=payload) +``` + +### ✅ Contoh Standar Thin WebSocket Route: + +```python +@router.websocket("/ws/{session_id}") +async def websocket_call_endpoint( + websocket: WebSocket, + session_id: str, + token: str | None = None, + voice_mode: str | None = None, +) -> None: + """Delegasikan seluruh handshake, receive loop, turn handling, dan cleanup ke CallSessionManager.""" + await call_session_manager.handle_connection( + websocket=websocket, + session_id=session_id, + token=token, + voice_mode=voice_mode, + ) +``` + +--- + +## 3. Centralized Dependency Injection (`app/api/dependencies.py`) + +Seluruh dependency provider dipusatkan di `app/api/dependencies.py`: + +```python +# 1. Database & Security Providers +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: ... +async def get_current_user(...) -> User: ... + +# 2. Service Factory Providers +async def get_auth_service(db: AsyncSession = Depends(get_db_session)) -> AuthService: + return AuthService(db) + +async def get_user_service(db: AsyncSession = Depends(get_db_session)) -> UserService: + return UserService(db) + +async def get_diary_service(db: AsyncSession = Depends(get_db_session)) -> DiaryService: + return DiaryService(db) +``` + +--- + +## 4. Pemisahan Modul Berat (*Heavyweight Modules*) + +Untuk domain yang memiliki kompleksitas tinggi dan multi-komponen, wajib dipecah menjadi sub-service kolaborator di dalam folder modulnya: + +### A. Modul Panggilan Suara & Real-time (`app/services/call/`) +- `session_manager.py`: Mengelola lifecycle WebSocket (`handle_connection`), state call (`idle`, `speaking`, `interrupted`), dan barge-in. +- `persistence.py`: Menangani penyimpanan sesi percakapan, transkrip turn, dan message duration ke database. +- `stream_buffer.py`: Mengelola buffer byte raw mic audio incoming. +- `turn_aggregator.py`: Mengagregasi turn pengguna dan deteksi completion. +- `turn_detector.py`: Evaluasi acoustic energy (RMS) dan sentence completion. + +### B. Modul Deteksi Emosi (`app/services/emotion/`) +- `ml_detector.py`: Inferensi machine learning klasifikasi emosi suara/teks. +- `analyzer.py`: Heuristik fallback dan mapper bobot 7-emosi ke UI. + +### C. Modul Percakapan & Diary (`app/services/conversation/` & `app/services/diary/`) +- Pisahkan operasi streaming SSE (misal ringkasan LLM token-by-token) ke dalam `summary_stream_service.py`. +- Pisahkan sintesis AI harian ke dalam `diary_generator.py` dan CRUD database ke dalam `diary_service.py`. + +--- + +## 5. Kontrak Data Pydantic (Request & Response) + +1. Semua data yang keluar dari API wajib melalui model Pydantic di `app/schemas/{module}/responses.py`. +2. Model Pydantic wajib mengaktifkan `from_attributes = True` jika membaca langsung dari model SQLAlchemy ORM: + ```python + class ConfigDict: + from_attributes = True + ``` +3. Naming convention: + - Request models berakhiran `Request` (contoh: `CreateDiaryRequest`, `RegisterRequest`). + - Response models berakhiran `Response` atau `Item` (contoh: `DiaryEntryResponse`, `TokenResponse`, `MessageItem`). + - Query filter models berakhiran `Filter` atau `Params` (contoh: `ConversationQueryParams`). + +--- + +## 6. Protokol Verifikasi & Anti-Regression + +Sebelum menyatakan pekerjaan refaktor atau pembuatan API baru selesai: +1. Jalankan `make test-backend` untuk memastikan semua endpoint lolos uji integritas. +2. Pastikan tidak ada `dict[str, Any]` yang tersisa sebagai tipe return di fungsi router. +3. Jalankan `make lint` dan `make format` untuk menjamin kerapian kode sesuai standar Ruff. diff --git a/.agents/rules/cohesion-and-modularity.md b/.agents/rules/cohesion-and-modularity.md index dddb901..a0a691f 100644 --- a/.agents/rules/cohesion-and-modularity.md +++ b/.agents/rules/cohesion-and-modularity.md @@ -41,15 +41,31 @@ Panduan ini mengatur standar modularitas kode pada monorepo Eva AI menggunakan p 1. **Pemisahan I/O & Realtime dari Persistensi**: Service yang menangani koneksi real-time (WebSockets, STT/TTS streaming) tidak boleh mencampur transaksi SQL langsung di dalam perulangan event. Delegasikan operasi database ke *Persistence Collaborator*. -2. **Peran Router yang Ramping**: - Router FastAPI (`app/api/routes/`) hanya bertugas memvalidasi schema HTTP request, memanggil domain service, dan mengembalikan DTO. Dilarang menaruh query SQL mentah kompleks di dalam router handler. -3. **Cohesive Domain Services**: - Bila sebuah service mulai mengelola lebih dari 3 entitas database atau mencampur komputasi teks dengan operasi I/O, pecah menjadi service kolaborator yang terfokus. +2. **Peran Router yang Ramping (Thin Controllers)**: + Router FastAPI (`app/api/routes/`) wajib ramping (3–5 baris per handler), hanya bertugas memvalidasi schema HTTP request, memanggil domain service via `Depends()`, dan mengembalikan Pydantic `response_model`. Dilarang menaruh query SQL mentah kompleks atau kalkulasi bisnis di dalam router handler. +3. **Cohesive Domain Services & Modular Packaging**: + Bila sebuah domain memiliki beberapa sub-komponen berat (seperti panggilan suara, deteksi emosi, orkestrasi AI), kumpulkan ke dalam direktori paket `app/services/{module}/nama_service.py` dan `app/schemas/{module}/` dengan barrel export `__init__.py`, alih-alih menumpuk file flat di root folder `services/`. + Lihat panduan lengkap di [.agents/rules/backend-clean-architecture.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/backend-clean-architecture.md). --- -## 4. Checklist Saat Merancang atau Merefaktor Komponen +## 4. Prinsip Component Reuse & Single Source of Truth +Sebelum menulis elemen UI baru atau menduplikasi styling secara *inline* di dalam sebuah screen: +1. **Wajib Memeriksa Komponen Reusable yang Sudah Ada**: + - Periksa direktori komponen global `apps/eva_mobile/lib/widgets/` (misalnya `AppBackButton`, `GlassCard`, `AppSnackBar`, `CustomButton`, `CustomTextField`, `SkeletonShimmer`, `StaggeredEntrance`). + - Periksa komponen tingkat fitur di `apps/eva_mobile/lib/features//presentation/widgets/`. +2. **Dilarang Menulis Ulang Komponen Standar Secara Inline**: + - Jangan membuat tombol *back* manual (`IconButton(icon: Icon(Icons.arrow_back...))` atau `Container(...)` bulat) secara terpisah di setiap screen. Selalu gunakan `AppBackButton`. + - Jangan membuat dialog / card / snack bar secara *ad-hoc* jika sudah tersedia komponen terstandarisasi. +3. **Ekstraksi Jika Dipakai di Lebih Dari 1 Tempat**: + - Jika sebuah komponen UI memiliki pola visual atau fungsi umum yang dibutuhkan oleh $\ge 2$ layar, ekstraksi menjadi reusable widget di `lib/widgets/` alih-alih menduplikasinya. + +--- + +## 5. Checklist Saat Merancang atau Merefaktor Komponen + +- [ ] Apakah sudah memeriksa `apps/eva_mobile/lib/widgets/` untuk memastikan belum ada komponen yang melayani kebutuhan serupa (misal `AppBackButton`, `GlassCard`)? $\rightarrow$ *Wajib gunakan komponen yang sudah ada jika tersedia* - [ ] Apakah sub-widget ini memiliki `Timer` atau `AnimationController` sendiri yang bisa berjalan independen dari layarnya? $\rightarrow$ *Ekstraksi ke widgets/* - [ ] Apakah ada class `CustomPainter` yang ditulis di bawah screen? $\rightarrow$ *Ekstraksi ke widgets/* - [ ] Apakah modal bottom sheet ini cukup panjang dan memiliki alur interaksinya sendiri? $\rightarrow$ *Ekstraksi ke widgets/* diff --git a/.agents/rules/github-coordination.md b/.agents/rules/github-coordination.md index 97352ee..6ed3a14 100644 --- a/.agents/rules/github-coordination.md +++ b/.agents/rules/github-coordination.md @@ -42,6 +42,7 @@ Backlog ───> Ready ───> In Progress ───> In Review ─ ``` - **Human-Owned Tasks**: Every task has a single human developer owner. AI agents code only on the task selected by the developer and must never auto-claim or auto-assign tasks. +- **Developer Review Gate for Issue Lifecycle**: Adding, editing, closing, or deleting GitHub issues requires prior review and explicit confirmation by the human developer. The AI agent must present the draft/dry-run and await approval before modifying GitHub state. - **Evidence-Based Progress Updates**: Task transitions require concrete evidence: - `Ready ➔ In Progress`: Active task branch matching `{type}/{issue}-{slug}`. - `In Progress ➔ Blocked`: Explicit blocker reason (minimum 3 characters). diff --git a/.agents/rules/native-gh-execution.md b/.agents/rules/native-gh-execution.md index 0944173..7284724 100644 --- a/.agents/rules/native-gh-execution.md +++ b/.agents/rules/native-gh-execution.md @@ -28,26 +28,32 @@ Always use these targets unless explicitly overridden by the user: --- -## 3. Autonomous Issue & Project Management Policy +## 3. Issue & Project Management Policy (Mandatory Developer Review Gate) -When the user asks to create an issue, start a feature/bugfix, or add a task: -1. **Never ask the user to open the browser or create the issue manually.** +When the user asks to create an issue, start a feature/bugfix, or add/edit/delete a task: +1. **Never ask the user to open the browser or create/edit the issue manually.** 2. Formulate a structured GitHub Issue title and body matching repository standards: - `## Summary` - `## Scope / Out of Scope` - `## Acceptance Criteria` - `## Definition of Done` -3. Execute the automated script: - ```bash - python3 scripts/agentic/gh_project.py create-issue \ - --title "" \ - --body "<Structured Body>" \ - --type <feat|fix|refactor|docs|test|chore> \ - --status <Backlog|Ready|In Progress> \ - --priority <Urgent|High|Medium|Low> \ - --assignee ekyaaa - ``` -4. If starting work immediately on that issue: +3. **MANDATORY REVIEW GATE**: Before executing actual creation, editing, closing, or deletion on GitHub: + - Present the structured proposal / diff / impact to the developer. + - Run `--dry-run` to preview the command payload: + ```bash + python3 scripts/agentic/gh_project.py create-issue \ + --title "<Title>" \ + --body "<Structured Body>" \ + --type <feat|fix|refactor|docs|test|chore> \ + --status <Backlog|Ready|In Progress> \ + --priority <Urgent|High|Medium|Low> \ + --assignee ekyaaa \ + --dry-run + ``` + - **Ask the developer for explicit review and confirmation.** +4. **Execute Only After Explicit Developer Approval**: + - Run the script without `--dry-run` once the developer approves. +5. If starting work immediately on that approved issue: - Create the task branch using `start-task.py`: ```bash python3 scripts/agentic/start-task.py --type <type> --issue <number> --slug <slug> diff --git a/.agents/skills/flutter-workflow/SKILL.md b/.agents/skills/flutter-workflow/SKILL.md index 0abce0b..323523b 100644 --- a/.agents/skills/flutter-workflow/SKILL.md +++ b/.agents/skills/flutter-workflow/SKILL.md @@ -11,17 +11,17 @@ Runbook for developing, building, and testing `apps/eva_mobile`. From the repository root: ```bash -# Run on physical Android device connected via USB (auto adb reverse port 8888): -make mobile-usb - -# Run on Android Emulator (auto routes to 10.0.2.2:8888): -make mobile-emu - -# Run with Mock Data (no backend required): -make mobile-mock - -# Run on Chrome Browser Simulator: -make mobile-web +# DEFAULT: Local API Stack (Port 8888) +make mobile-usb # HP fisik via USB (auto adb reverse port 8888 -> 127.0.0.1:8888) +make mobile-emu # Android Emulator (auto route gateway 10.0.2.2:8888) +make mobile-web # Chrome Web Simulator (127.0.0.1:8888) +make mobile-mock # Offline Mock Data (tanpa backend) + +# CLOUD: Production Cloud Stack (eva.nexacode.dev) +make mobile-usb-cloud # HP fisik nembak production cloud +make mobile-emu-cloud # Android Emulator nembak production cloud +make mobile-web-cloud # Chrome Web nembak production cloud +make mobile-cloud # Shortcut alias ke mobile-usb-cloud ``` ## 2. Manual Profile Execution diff --git a/.agents/skills/manage-github-project/SKILL.md b/.agents/skills/manage-github-project/SKILL.md index 78ababc..7be893e 100644 --- a/.agents/skills/manage-github-project/SKILL.md +++ b/.agents/skills/manage-github-project/SKILL.md @@ -1,23 +1,69 @@ --- name: manage-github-project -description: Automate creation, management, linking, and status updates of GitHub Issues and Project v2 items natively using the host GitHub CLI. +description: Automate creation, management, linking, and status updates of GitHub Issues and Project v2 items natively using the host GitHub CLI, with mandatory developer review before any issue mutations. --- # Manage GitHub Project & Issues Runbook -This skill automates all interactions with GitHub Issues and GitHub Project #2 (`https://github.com/orgs/Nexa-Code-Studio/projects/2`) using native `gh` commands without manual web UI interaction. +This skill automates interactions with GitHub Issues and GitHub Project #2 (`https://github.com/orgs/Nexa-Code-Studio/projects/2`) using native `gh` commands without requiring manual web UI interaction. + +--- + +## ⚠️ Mandatory Rule: Developer Review Gate (Aturan Wajib Review Developer) + +> **KEBIJAKAN MUTLAK:** AI agent **DILARANG KERAS** menambah (create), mengedit (edit/update), menutup (close), atau menghapus (delete) issue maupun item Project GitHub secara sepihak/otomatis tanpa meminta developer mereview dan memberikan konfirmasi terlebih dahulu. + +### Alur Wajib (4 Langkah): + +1. **Formulasi Draf (Drafting)**: + - Siapkan rincian lengkap issue sesuai format standar repositori (`## Summary`, `## Scope / Out of Scope`, `## Acceptance Criteria`, `## Definition of Done`, label/type, assignee, initial status, priority). + - Untuk edit/hapus/tutup: siapkan nomor issue target dan parameter spesifik yang akan diubah beserta alasannya. +2. **Tampilkan Preview Draf / Dry-Run**: + - Tampilkan draf lengkap secara terstruktur kepada developer di chat, atau jalankan perintah dengan opsi `--dry-run` untuk memperlihatkan muatan persis yang akan dikirim ke GitHub. +3. **Minta Konfirmasi Developer (Explicit Prompt)**: + - Tanyakan secara eksplisit kepada developer untuk mereview dan memberikan persetujuan: + > *"Silakan review draf issue di atas sebelum saya eksekusi ke GitHub. Apakah rincian ini sudah sesuai atau ada yang perlu diperbaiki?"* +4. **Eksekusi Hanya Setelah Disetujui (Approved Execution)**: + - Jalankan perintah live (tanpa `--dry-run`) **HANYA SETELAH** developer menyatakan persetujuan secara eksplisit (misal: "Setuju", "Lanjutkan", "Oke", "Approved"). + +--- ## When to Use -- When the user asks to create an issue, task, bug, or feature without manual UI steps. -- When linking `eva-ecosystem` to Project #2. -- When querying the current items and board columns on Project #2. -- When updating status or priority on the project board. +- Ketika developer meminta untuk membuat issue, bugfix, feature, chore, atau task baru. +- Ketika developer meminta untuk mengedit title, body, label, atau assignee issue yang sudah ada. +- Ketika developer meminta untuk menutup atau menghapus issue di GitHub. +- Ketika menghubungkan repository `eva-ecosystem` ke Project #2. +- Ketika menginspeksi atau membaca board item pada Project #2. + +--- ## Execution Procedures -### 1. Create Issue and Automatically Add to Project #2 +### 1. Menambah Issue Baru (Create Issue) + +#### Langkah 1 (Review Phase - Jalankan Dry-Run): +```bash +python3 scripts/agentic/gh_project.py create-issue \ + --title "Implement Voice Activity Detection" \ + --type feat \ + --status Ready \ + --priority High \ + --assignee ekyaaa \ + --body "## Summary +Voice activity detection to reduce latency. + +## Acceptance Criteria +- [ ] VAD threshold configurable +- [ ] Passing unit tests + +## Definition of Done +- Code reviewed and tests passing" \ + --dry-run +``` +*Tampilkan output draf kepada developer dan tunggu konfirmasi.* +#### Langkah 2 (Execution Phase - Setelah Developer Setuju): ```bash python3 scripts/agentic/gh_project.py create-issue \ --title "Implement Voice Activity Detection" \ @@ -36,7 +82,74 @@ Voice activity detection to reduce latency. - Code reviewed and tests passing" ``` -### 2. Link Repository `eva-ecosystem` to Project #2 +--- + +### 2. Mengedit Issue yang Ada (Edit Issue) + +#### Langkah 1 (Review Phase - Jalankan Dry-Run): +```bash +python3 scripts/agentic/gh_project.py edit-issue \ + --issue 128 \ + --title "Updated Title" \ + --add-label "backend" \ + --dry-run +``` +*Tampilkan perubahan kepada developer dan minta review.* + +#### Langkah 2 (Execution Phase - Setelah Developer Setuju): +```bash +python3 scripts/agentic/gh_project.py edit-issue \ + --issue 128 \ + --title "Updated Title" \ + --add-label "backend" +``` + +--- + +### 3. Menutup Issue (Close Issue) + +#### Langkah 1 (Review Phase - Jalankan Dry-Run): +```bash +python3 scripts/agentic/gh_project.py close-issue \ + --issue 128 \ + --comment "Resolved via PR #15" \ + --reason completed \ + --dry-run +``` +*Minta konfirmasi developer sebelum menutup issue.* + +#### Langkah 2 (Execution Phase - Setelah Developer Setuju): +```bash +python3 scripts/agentic/gh_project.py close-issue \ + --issue 128 \ + --comment "Resolved via PR #15" \ + --reason completed +``` + +--- + +### 4. Menghapus Issue (Delete Issue) + +> [!CAUTION] +> Menghapus issue bersifat destruktif dan permanen. Pastikan developer secara sadar dan eksplisit menginstruksikan penghapusan. + +#### Langkah 1 (Review Phase - Jalankan Dry-Run): +```bash +python3 scripts/agentic/gh_project.py delete-issue \ + --issue 128 \ + --dry-run +``` +*Tanyakan developer: "Apakah Anda yakin ingin menghapus issue #128 secara permanen?"* + +#### Langkah 2 (Execution Phase - Setelah Developer Setuju): +```bash +python3 scripts/agentic/gh_project.py delete-issue \ + --issue 128 +``` + +--- + +### 5. Link Repository `eva-ecosystem` ke Project #2 (Read/Link Only) ```bash python3 scripts/agentic/gh_project.py link-repo \ @@ -45,7 +158,9 @@ python3 scripts/agentic/gh_project.py link-repo \ --project 2 ``` -### 3. View Project #2 Items and Statuses +--- + +### 6. Melihat Item & Status Project #2 (Read-Only) ```bash python3 scripts/agentic/gh_project.py view-project \ diff --git a/.agents/skills/monorepo-ops/SKILL.md b/.agents/skills/monorepo-ops/SKILL.md index 9e4fd47..1b397ab 100644 --- a/.agents/skills/monorepo-ops/SKILL.md +++ b/.agents/skills/monorepo-ops/SKILL.md @@ -64,7 +64,7 @@ make db-shell ## 4. Tests & Quality Checks ```bash -# Run backend & AI package tests via Docker +# Run backend & AI package tests via Docker (includes Clean Architecture guardrail test) make test-backend # Run mobile tests @@ -74,3 +74,10 @@ make test-mobile make lint make format ``` + +> **Clean Architecture Guardrail:** +> Running `make test-backend` automatically validates `test_clean_architecture.py`, ensuring: +> 1. Zero loose `.py` files in `app/services/` or `app/schemas/` root. +> 2. Zero inline SQL queries (`select()`, `db.execute()`, `db.commit()`) in router files. +> 3. Zero duplicated `_get_current_user` private functions. +> 4. All router endpoints are thin controllers ($\le 15$ lines) returning Pydantic `response_model`. diff --git a/.agents/skills/project-state-sync/SKILL.md b/.agents/skills/project-state-sync/SKILL.md index faf9d28..aead8b6 100644 --- a/.agents/skills/project-state-sync/SKILL.md +++ b/.agents/skills/project-state-sync/SKILL.md @@ -10,6 +10,7 @@ This skill defines the evidence requirements and operational procedures for tran ## Overview State transitions are **evidence-based and deterministic**: +- Mutasi lifecycle issue (tambah, edit, tutup, atau hapus) wajib melalui review dan persetujuan eksplisit developer. - The agent must **never** infer task completion merely because code was edited. - A passing unit test alone does **not** mark a task `Done`. - An agent crash or process termination never triggers automated state transitions. diff --git a/.agents/skills/sync-project-state/SKILL.md b/.agents/skills/sync-project-state/SKILL.md index 3523bb4..a833e47 100644 --- a/.agents/skills/sync-project-state/SKILL.md +++ b/.agents/skills/sync-project-state/SKILL.md @@ -7,6 +7,9 @@ description: Synchronize task status and GitHub Project v2 based on verifiable e This skill validates concrete evidence before updating a task's state in `.agent/task-state.json` and the bound GitHub Project v2. +> [!IMPORTANT] +> **Developer Review Gate**: Mutasi status terminal (seperti menutup issue ke `Done`) atau modifikasi issue GitHub harus didahului konfirmasi dan review dari developer. + ## Status Machine ```text diff --git a/AGENTS.md b/AGENTS.md index a0a5e0e..0f2cfc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,9 +32,11 @@ - **Sweet-Spot Cohesion & Modularity**: - Keep files cohesive; extract independent timers, custom painters, and multi-step modals into dedicated widget files while keeping local layout together. - See [.agents/rules/cohesion-and-modularity.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/cohesion-and-modularity.md) for natural separation criteria. +- **Component Reuse & Single Source of Truth**: + - Before creating new UI elements or duplicate styling, always verify and reuse existing widgets in `apps/eva_mobile/lib/widgets/` (e.g. `AppBackButton`, `GlassCard`, `AppSnackBar`) or feature widgets. Never declare manual inline back buttons across screens. - **Hierarchical Workspace Scopes**: - Flutter Mobile: Follow [apps/eva_mobile/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/eva_mobile/AGENTS.md) (Clean Architecture, Riverpod DI, Pure Dart models). - - Backend API: Follow [apps/backend/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/backend/AGENTS.md) (FastAPI routes, Pydantic schemas, Alembic migrations, FastMCP). + - Backend API: Follow [apps/backend/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/backend/AGENTS.md) and [.agents/rules/backend-clean-architecture.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/backend-clean-architecture.md) (Thin routes 3-5 lines, modular services/schemas, centralized DI, Alembic migrations). - AI & Orchestration: Follow [packages/ai/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/packages/ai/AGENTS.md) (Abstract Base Classes, LLM/TTS factories, graceful fallbacks). - **Available Skills**: - `monorepo-ops`: Docker dev/prod, Alembic migrations, database resets, and backend service runner. @@ -61,6 +63,7 @@ ## 5. Supervised Autonomous Development Coordination Policies - **Human-Owned Tasks**: Every task has a single human developer owner. AI agents code only on the task selected by the developer and must never auto-claim or auto-assign tasks. +- **Issue Mutation Review Gate**: The AI agent must never create, edit, close, or delete GitHub Issues autonomously. All additions, updates, or deletions must be presented to the developer (via draft/dry-run) and receive explicit review and approval before execution. - **Identity Preflight Mandatory**: Before modifying code or updating task status, verify local identity via `python3 scripts/agentic/doctor.py`. If identity is missing, prompt developer to run `python3 scripts/agentic/bootstrap.py`. - **Session Context Reconciliation**: At the beginning of a session, inspect active context via `python3 scripts/agentic/tasks.py` to reconcile local and remote state. - **Strict Branch & Worktree Naming**: All branches must follow `{type}/{issue-number}-{short-kebab-slug}` where type is one of `feat`, `fix`, `refactor`, `docs`, `test`, `chore`. Personal branches (`dev/{name}`) are prohibited. diff --git a/Makefile b/Makefile index 97ae07c..b4bd57f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev dev-d dev-build dev-api dev-mcp dev-worker prod prod-down logs logs-api logs-worker logs-prod ps down db-shell migrate migration-gen db-reset seed mobile-usb mobile-emu mobile-mock mobile-web counselor-mock counselor-web test-counselor web-dev web-build web-lint test test-backend test-mobile lint format doctor tasks bootstrap test-agentic link-project view-project +.PHONY: help dev dev-d dev-build dev-api dev-mcp dev-worker prod prod-down logs logs-api logs-worker logs-prod ps down db-shell migrate migration-gen db-reset seed mobile-usb mobile-emu mobile-mock mobile-hybrid mobile-emu-hybrid mobile-web-hybrid mobile-web mobile-usb-cloud mobile-emu-cloud mobile-web-cloud mobile-cloud counselor-mock counselor-web test-counselor web-dev web-build web-lint test test-backend test-mobile lint format doctor tasks bootstrap test-agentic link-project view-project help: @echo "EVA Ecosystem Management Commands (Full Docker Environment):" @@ -29,17 +29,24 @@ help: @echo " make seed - Jalankan seeder akun & mental health knowledge via Docker" @echo "" @echo " Mobile Applications (Eva Mobile):" - @echo " make mobile-usb - Jalankan Eva Mobile di HP Fisik via USB (auto adb reverse 8888)" - @echo " make mobile-emu - Jalankan Eva Mobile di Android Emulator (route 10.0.2.2:8888)" - @echo " make mobile-mock - Jalankan Eva Mobile dengan Mock Data lokal" - @echo " make mobile-web - Jalankan Eva Mobile di Chrome Web Simulator" - @echo " make counselor-mock- Jalankan Eva Counselor (Psikolog) dengan Mock Data" - @echo " make counselor-web - Jalankan Eva Counselor di Chrome Web Simulator" + @echo " make mobile-usb - Jalankan di HP Fisik (DEFAULT: API Lokal via reverse 8888)" + @echo " make mobile-emu - Jalankan di Android Emulator (DEFAULT: API Lokal 10.0.2.2:8888)" + @echo " make mobile-web - Jalankan di Chrome Web (DEFAULT: API Lokal 127.0.0.1:8888)" + @echo " make mobile-mock - Jalankan dengan Mock Data lokal (offline)" + @echo " make mobile-hybrid - Jalankan mode Hybrid di HP Fisik (Remote API + Auto Fallback Mock)" + @echo " make mobile-emu-hybrid - Jalankan mode Hybrid di Android Emulator" + @echo " make mobile-web-hybrid - Jalankan mode Hybrid di Chrome Web" + @echo " make mobile-usb-cloud - Jalankan di HP Fisik nembak ke Cloud (eva.nexacode.dev)" + @echo " make mobile-emu-cloud - Jalankan di Android Emulator nembak ke Cloud (eva.nexacode.dev)" + @echo " make mobile-web-cloud - Jalankan di Chrome Web nembak ke Cloud (eva.nexacode.dev)" + @echo " make mobile-cloud - Shortcut jalankan mobile nembak ke Cloud" + @echo " make counselor-mock - Jalankan Eva Counselor (Psikolog) dengan Mock Data" + @echo " make counselor-web - Jalankan Eva Counselor di Chrome Web Simulator" @echo "" @echo " Web Application (Eva Web Portal):" - @echo " make web-dev - Jalankan server development Vite apps/web (port 5174)" - @echo " make web-build - Jalankan build produksi apps/web (tsc -b && vite build)" - @echo " make web-lint - Jalankan code linter ESLint untuk apps/web" + @echo " make web-dev - Jalankan server development Vite apps/web (port 5174)" + @echo " make web-build - Jalankan build produksi apps/web (tsc -b && vite build)" + @echo " make web-lint - Jalankan code linter ESLint untuk apps/web" @echo "" @echo " Testing & Code Quality:" @echo " make test - Jalankan seluruh test suite (Backend via Docker & Mobile)" @@ -127,7 +134,8 @@ seed: docker compose exec -T api python -m app.db.seed # ------------------------------------------------------------------------------ -# Mobile Applications Runner (Physical USB / Emulator / Mock / Web) +# Mobile Applications Runner (Physical USB / Emulator / Web / Mock) +# DEFAULT: Local API (8888). Append '-cloud' to connect to production cloud. # ------------------------------------------------------------------------------ mobile-usb: @./scripts/run_mobile.sh usb $(ARGS) @@ -135,11 +143,31 @@ mobile-usb: mobile-emu: @./scripts/run_mobile.sh emu $(ARGS) +mobile-web: + @./scripts/run_mobile.sh web $(ARGS) + mobile-mock: @./scripts/run_mobile.sh mock $(ARGS) -mobile-web: - @./scripts/run_mobile.sh web $(ARGS) +mobile-hybrid: + @./scripts/run_mobile.sh hybrid $(ARGS) + +mobile-emu-hybrid: + @./scripts/run_mobile.sh emu-hybrid $(ARGS) + +mobile-web-hybrid: + @./scripts/run_mobile.sh web-hybrid $(ARGS) + +mobile-usb-cloud: + @./scripts/run_mobile.sh usb-cloud $(ARGS) + +mobile-emu-cloud: + @./scripts/run_mobile.sh emu-cloud $(ARGS) + +mobile-web-cloud: + @./scripts/run_mobile.sh web-cloud $(ARGS) + +mobile-cloud: mobile-usb-cloud counselor-mock: @APP=eva_counselor_mobile ./scripts/run_mobile.sh mock $(ARGS) diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 2cd7416..dfe17ae 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -1,13 +1,16 @@ # Backend (FastAPI, MCP & Workers) - Sub-Workspace Rules -> Scope: `apps/backend/**` +> Scope: `apps/backend/**` +> Mandat Arsitektur: Wajib mematuhi [.agents/rules/backend-clean-architecture.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/backend-clean-architecture.md) ## 1. Directory & Code Organization - **API (`apps/backend/api`)**: - - Routes in `app/api/routes` - - SQLAlchemy models in `app/models`, Pydantic schemas in `app/schemas` - - Domain business services in `app/services` - - Database sessions and engine in `app/db` + - **Routes (`app/api/routes`)**: Thin Controllers (3–5 baris per endpoint). Hanya mendeklarasikan HTTP path, `response_model`, dan delegasi ke service. + - **Centralized Dependencies (`app/api/dependencies.py`)**: Seluruh dependency (`get_current_user`, `get_db_session`, service factories) dipusatkan di sini. Dilarang duplikasi `_get_current_user` di file route. + - **Modular Schemas (`app/schemas/{module}/`)**: Kontrak Pydantic per domain (`requests.py`, `responses.py`, dan `__init__.py` barrel export). Dilarang mengembalikan `dict[str, Any]` tanpa Pydantic schema. + - **Modular Domain Services (`app/services/{module}/`)**: Logika bisnis, transaksi database, dan orkestrasi per domain (`{name}_service.py` dan `__init__.py`). + - **Database Models (`app/models`)**: SQLAlchemy ORM models dan declarative base. + - **Database Sessions & Seeders (`app/db`)**: Engine konfigurasi dan master data seeding. - **MCP (`apps/backend/mcp`)**: - FastMCP tool definitions in `app/tools` - Completely decoupled from direct HTTP router dependencies. @@ -15,17 +18,24 @@ - Async ARQ task definitions in `app/tasks` and worker loop in `app/workers`. ## 2. Abstraction & Dependency Injection -- Never instantiate database sessions or AI provider classes directly inside route handlers. +- Never instantiate database sessions (`AsyncSessionLocal()`), AI provider classes (`LLMFactory`), or services directly inside route handlers. - Always inject via FastAPI `Depends()`: + - User Identity: `user: User = Depends(get_current_user)` (dari `app.api.dependencies`) - Database: `db: AsyncSession = Depends(get_db_session)` + - Domain Services: `service: AuthService = Depends(get_auth_service)` - AI Providers: `llm: BaseLLMProvider = Depends(get_llm_provider)` - Services must be decoupled from FastAPI request/response lifecycle where possible to allow worker reuse. -## 3. Database & Migrations +## 3. Thin Router Invariant (3–5 Baris per Endpoint) +- Dilarang menulis raw SQL queries (`select()`, `db.execute()`, `db.commit()`) di dalam router handler. +- Dilarang melakukan kalkulasi emosi, validasi password manual, atau pembuatan token JWT di dalam router handler. +- Router hanya bertugas menerima request, memicu method service, dan mengembalikan response model Pydantic yang valid. + +## 4. Database & Migrations - Use Alembic for all schema changes (`alembic revision --autogenerate -m "..."`). - Never perform raw schema alterations in production migrations. - Test queries using async SQLAlchemy patterns (`select()`, `scalars()`). -## 4. Service Cohesion & Separation of Concerns -- Separate real-time event dispatching (WebSockets, streaming audio) from database persistence transactions. -- Keep route handlers focused on validation and delegating to services without inline SQL queries. +## 5. Service Cohesion & Separation of Concerns +- Modul berat (*heavyweight modules*) seperti voice call (`app/services/call/`) wajib memisahkan I/O real-time (WebSockets, audio streaming, turn detection) dari transaksi persistensi database (`persistence.py`). Seluruh koneksi WebSocket di router wajib didelegasikan langsung ke `call_session_manager.handle_connection`. +- Modul emosi (`app/services/emotion/`) wajib memisahkan detektor ML (`ml_detector.py`) dari heuristik fallback / formatting (`analyzer.py`). diff --git a/apps/backend/api/app/api/dependencies.py b/apps/backend/api/app/api/dependencies.py index a27bc5f..e25ab1a 100644 --- a/apps/backend/api/app/api/dependencies.py +++ b/apps/backend/api/app/api/dependencies.py @@ -1,8 +1,16 @@ from collections.abc import AsyncGenerator +import logging +from fastapi import Depends, Header, HTTPException, Query, status from redis.asyncio import Redis, from_url +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_db_session +from app.models.user import User from shared.config import settings +logger = logging.getLogger(__name__) + async def get_redis_client() -> AsyncGenerator[Redis, None]: """Dependency for providing Async Redis client.""" @@ -11,3 +19,93 @@ async def get_redis_client() -> AsyncGenerator[Redis, None]: yield client finally: await client.close() + + +def extract_auth_token( + authorization: str | None = Header(None), + token_query: str | None = Query(None, alias="token"), +) -> str: + """Extract raw bearer token from Authorization header or 'token' query parameter.""" + token = None + if authorization and authorization.startswith("Bearer "): + token = authorization.split("Bearer ")[1].strip() + elif token_query: + token = token_query.strip() + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Header Authorization Bearer atau token query diperlukan", + ) + return token + + +async def get_auth_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for AuthService.""" + from app.services.auth import AuthService + + return AuthService(db) + + +async def get_current_user( + token: str = Depends(extract_auth_token), + service=Depends(get_auth_service), +) -> User: + """Strictly authenticate current user from Bearer JWT access token or query parameter.""" + return await service.get_user_from_token(token) + + + +async def get_user_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for UserService.""" + from app.services.user import UserService + + return UserService(db) + + +async def get_diary_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for DiaryService.""" + from app.services.diary import DiaryService + + return DiaryService(db) + + +async def get_recommendation_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for RecommendationService.""" + from app.services.recommendation import RecommendationService + + return RecommendationService(db) + + +async def get_dass_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for DASSService.""" + from app.services.dass import DASSService + + return DASSService(db) + + +async def get_analytics_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for AnalyticsService.""" + from app.services.analytics import AnalyticsService + + return AnalyticsService(db) + + +async def get_conversation_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for ConversationService.""" + from app.services.conversation import ConversationService + + return ConversationService(db) + + +async def get_summary_stream_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for SummaryStreamService.""" + from app.services.conversation import SummaryStreamService + + return SummaryStreamService(db) + + + + + + diff --git a/apps/backend/api/app/api/routes/analytics.py b/apps/backend/api/app/api/routes/analytics.py index 613c77a..1cab3ab 100644 --- a/apps/backend/api/app/api/routes/analytics.py +++ b/apps/backend/api/app/api/routes/analytics.py @@ -1,66 +1,22 @@ import logging -from typing import Any -import uuid -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends, Query -from app.core.security import decode_access_token -from app.db.session import get_db_session +from app.api.dependencies import get_analytics_service, get_current_user from app.models.user import User -from app.services.analytics_service import AnalyticsService +from app.schemas.analytics import MonitoringDataResponse +from app.services.analytics import AnalyticsService logger = logging.getLogger(__name__) router = APIRouter(prefix="/analytics", tags=["Analytics"]) -async def _get_current_user( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> User: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - -@router.get("/monitoring") +@router.get("/monitoring", response_model=MonitoringDataResponse) async def get_monitoring_data( period: str = Query("today", description="Period: today | week | month"), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: + user: User = Depends(get_current_user), + service: AnalyticsService = Depends(get_analytics_service), +) -> MonitoringDataResponse: """Fetch emotional rhythm, mental health risks, and AI summary for the requested period.""" - return await AnalyticsService.get_monitoring_data(user.id, period, db) - - + return await service.get_data(user.id, period=period) diff --git a/apps/backend/api/app/api/routes/auth.py b/apps/backend/api/app/api/routes/auth.py index d4b0bab..e2e82c6 100644 --- a/apps/backend/api/app/api/routes/auth.py +++ b/apps/backend/api/app/api/routes/auth.py @@ -1,233 +1,73 @@ import logging -from typing import Any -import uuid - -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status -from pydantic import BaseModel, EmailStr -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.security import ( - create_access_token, - create_refresh_token, - decode_access_token, - decode_refresh_token, - hash_password, - verify_password, -) -from app.db.session import get_db_session + +from fastapi import APIRouter, Depends, Query, status + +from app.api.dependencies import get_auth_service, get_current_user from app.models.user import User +from app.schemas.auth import ( + EmailCheckResponse, + LoginRequest, + LogoutResponse, + RefreshRequest, + RegisterRequest, + TokenResponse, + UserAuthResponse, +) +from app.services.auth import AuthService logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["Auth"]) -class LoginRequest(BaseModel): - email: EmailStr - password: str +@router.get("/check-email", response_model=EmailCheckResponse) +async def check_email( + email: str = Query(..., description="Email address to check"), + service: AuthService = Depends(get_auth_service), +) -> EmailCheckResponse: + """Check if an email address is available for registration.""" + return await service.check_email(email) + + +@router.post("/login", response_model=TokenResponse) +async def login( + payload: LoginRequest, + service: AuthService = Depends(get_auth_service), +) -> TokenResponse: + """Authenticate user and issue JWT access & refresh tokens.""" + return await service.login(payload) -class RegisterRequest(BaseModel): - name: str - email: EmailStr - password: str +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def register( + payload: RegisterRequest, + service: AuthService = Depends(get_auth_service), +) -> TokenResponse: + """Register a new user account and return initial tokens.""" + return await service.register(payload) -class RefreshRequest(BaseModel): - refresh_token: str +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token( + payload: RefreshRequest, + service: AuthService = Depends(get_auth_service), +) -> TokenResponse: + """Refresh an expired access token using a valid refresh token.""" + return await service.refresh_token(payload) -@router.get("/check-email") -async def check_email( - email: str = Query(..., description="Email address to check"), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - """Check if an email address is available for registration.""" - clean_email = email.strip().lower() - if not clean_email or "@" not in clean_email or "." not in clean_email: - return { - "available": False, - "email": clean_email, - "message": "Format email tidak valid", - } - - query = select(User).where(User.email == clean_email) - res = await db.execute(query) - existing = res.scalar_one_or_none() - - if existing: - return { - "available": False, - "email": clean_email, - "message": "Email sudah terdaftar", - } - - return { - "available": True, - "email": clean_email, - "message": "Email tersedia", - } - - -@router.post("/login") -async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db_session)) -> dict[str, Any]: - clean_email = payload.email.strip().lower() - query = select(User).where(User.email == clean_email) - res = await db.execute(query) - user = res.scalar_one_or_none() - - if not user or not verify_password(payload.password, user.password_hash): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Email atau kata sandi tidak valid", - ) - - display_name = user.display_name or user.username or "User Eva" - access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) - refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) - - return { - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": "bearer", - "user": { - "id": str(user.id), - "name": display_name, - "email": user.email, - }, - } - - -@router.post("/register") -async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db_session)) -> dict[str, Any]: - clean_email = payload.email.strip().lower() - query = select(User).where(User.email == clean_email) - res = await db.execute(query) - existing = res.scalar_one_or_none() - - if existing: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email sudah terdaftar", - ) - - clean_name = payload.name.strip() - user = User( - email=clean_email, - username=clean_name, - display_name=clean_name, - password_hash=hash_password(payload.password), - is_active=True, - is_verified=True, - ) - db.add(user) - await db.commit() - await db.refresh(user) - - display_name = user.display_name or clean_name - access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) - refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) - - return { - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": "bearer", - "user": { - "id": str(user.id), - "name": display_name, - "email": user.email, - }, - } - - -@router.post("/refresh") -async def refresh_token(payload: RefreshRequest, db: AsyncSession = Depends(get_db_session)) -> dict[str, Any]: - decoded = decode_refresh_token(payload.refresh_token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except ValueError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID token tidak valid", - ) - - query = select(User).where(User.id == u_uuid) - res = await db.execute(query) - user = res.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User tidak ditemukan atau tidak aktif", - ) - - display_name = user.display_name or user.username or "User Eva" - new_access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) - new_refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) - - return { - "access_token": new_access_token, - "refresh_token": new_refresh_token, - "token_type": "bearer", - "user": { - "id": str(user.id), - "name": display_name, - "email": user.email, - }, - } - - -@router.get("/me") +@router.get("/me", response_model=UserAuthResponse) async def get_me( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan", - ) - - return { - "id": str(user.id), - "name": user.display_name or user.username or "User Eva", - "email": user.email, - } - - -@router.post("/logout") -async def logout() -> dict[str, str]: - return {"message": "Logged out successfully"} + current_user: User = Depends(get_current_user), + service: AuthService = Depends(get_auth_service), +) -> UserAuthResponse: + """Get the currently authenticated user's profile.""" + return service.get_user_profile(current_user) + + +@router.post("/logout", response_model=LogoutResponse) +async def logout( + service: AuthService = Depends(get_auth_service), +) -> LogoutResponse: + """Acknowledge user logout.""" + return service.logout() diff --git a/apps/backend/api/app/api/routes/call.py b/apps/backend/api/app/api/routes/call.py index 4689e9f..098b566 100644 --- a/apps/backend/api/app/api/routes/call.py +++ b/apps/backend/api/app/api/routes/call.py @@ -1,10 +1,8 @@ -import json import logging -from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, WebSocket -from app.services.audio_stream_buffer import audio_buffer_service -from app.services.call_session_manager import call_session_manager +from app.services.call import call_session_manager logger = logging.getLogger(__name__) @@ -18,54 +16,10 @@ async def websocket_call_endpoint( token: str | None = None, voice_mode: str | None = None, ) -> None: - """Bi-directional WebSocket endpoint for AI Phone Call sessions. - - Supports: - - Binary frames: Incoming raw microphone audio bytes (forwarded to audio_buffer_service). - - Text/JSON frames: Control events (`start_call`, `user_transcript`, `user_interrupted`, `end_call`, `ping`). - """ - await websocket.accept() - session = call_session_manager.register_session(session_id, websocket, auth_token=token, voice_mode=voice_mode) - logger.info(f"🔌 [WEBSOCKET CONNECTED] Session: {session_id} (Voice Mode: {session.voice_mode})") - - try: - # Pre-load previous conversation history from DB so DeepSeek LLM remembers past context - await call_session_manager.load_conversation_history(session) - - # Notify client connection accepted - await websocket.send_json({ - "type": "call_connected", - "session_id": session_id, - "message": "Connected to Eva AI Phone Service", - }) - - while True: - message = await websocket.receive() - if "bytes" in message and message["bytes"]: - audio_bytes = message["bytes"] - logger.info(f"🎙️ [INCOMING MIC AUDIO] Session {session_id}: Received {len(audio_bytes)} raw mic bytes") - await audio_buffer_service.handle_incoming_audio_chunk(session_id, audio_bytes) - - elif "text" in message and message["text"]: - try: - payload = json.loads(message["text"]) - event_type = payload.get("type") - - if event_type == "end_call": - duration_sec = payload.get("duration_seconds") - logger.info(f"🛑 [CALL ENDED BY USER] Session {session_id}, Duration: {duration_sec}s") - await call_session_manager.update_session_end(session_id, duration_seconds=duration_sec) - break - - await call_session_manager.handle_event(session, payload) - - except json.JSONDecodeError: - logger.warning(f"⚠️ [INVALID JSON] Session {session_id}: Received malformed JSON") - - except WebSocketDisconnect: - logger.info(f"🔌 [WEBSOCKET DISCONNECTED] Session {session_id}") - except Exception as e: - logger.error(f"❌ [WEBSOCKET ERROR] Session {session_id}: {e}") - finally: - call_session_manager.unregister_session(session_id) - audio_buffer_service.clear_session(session_id) + """Bi-directional WebSocket endpoint for AI Phone Call sessions.""" + await call_session_manager.handle_connection( + websocket=websocket, + session_id=session_id, + token=token, + voice_mode=voice_mode, + ) diff --git a/apps/backend/api/app/api/routes/conversations.py b/apps/backend/api/app/api/routes/conversations.py index 45be255..2a12621 100644 --- a/apps/backend/api/app/api/routes/conversations.py +++ b/apps/backend/api/app/api/routes/conversations.py @@ -1,557 +1,87 @@ -import json -import math -import uuid -from collections import Counter -from datetime import UTC, datetime, date, time, timezone - import logging -from typing import Any +import uuid -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from fastapi import APIRouter, Depends, Query from fastapi.responses import StreamingResponse -from pydantic import BaseModel -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload -from app.core.security import decode_access_token -from app.core.tz import get_wib_day_range_utc, get_wib_today, to_wib -from app.db.session import AsyncSessionLocal, get_db_session -from app.models.conversation import Conversation, Message, ConversationSummary -from app.models.diary import DiaryEntry -from app.models.enums import ConversationStatus, MessageType -from app.models.safety import EmotionAnalysis +from app.api.dependencies import ( + get_conversation_service, + get_current_user, + get_summary_stream_service, +) from app.models.user import User -from app.services.conversation_service import ( - _analyze_message_emotion_fallback, - _format_conversation, - _format_duration_str, - _format_time_local, - _get_message_emotion, +from app.schemas.conversation import ( + ConversationDetailResponse, + ConversationListResponse, + SendMessageRequest, + SentMessageResponse, + TodayConversationResponse, + TodayMessagesResponse, ) -from app.services.ml_emotion_detector import MLEmotionDetectorService -from packages.ai.factories.llm_factory import LLMFactory -from packages.ai.interfaces.llm import LLMMessage +from app.services.conversation import ConversationService, SummaryStreamService logger = logging.getLogger(__name__) router = APIRouter(prefix="/conversations", tags=["Conversations"]) -class SendMessageRequest(BaseModel): - content: str - modality: str = "text" - - -async def _get_current_user( - authorization: str | None = Header(None), - token_query: str | None = Query(None, alias="token"), - db: AsyncSession = Depends(get_db_session), -) -> User: - token = None - if authorization and authorization.startswith("Bearer "): - token = authorization.split("Bearer ")[1].strip() - elif token_query: - token = token_query.strip() - - if not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer atau token query diperlukan", - ) - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - -@router.get("") +@router.get("", response_model=ConversationListResponse) async def get_conversations( page: int = Query(1, ge=1, description="Page number for pagination"), limit: int = Query(10, ge=1, le=100, description="Items per page"), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - count_query = ( - select(func.count()) - .select_from(Conversation) - .where(Conversation.user_id == user.id) - ) - total_res = await db.execute(count_query) - total_items = total_res.scalar() or 0 - - offset = (page - 1) * limit - query = ( - select(Conversation) - .options( - selectinload(Conversation.messages), - selectinload(Conversation.summaries), - ) - .where(Conversation.user_id == user.id) - .order_by(Conversation.started_at.desc()) - .offset(offset) - .limit(limit) - ) - res = await db.execute(query) - convs = res.scalars().all() - - message_ids = [m.id for c in convs for m in c.messages] - emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} - if message_ids: - emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) - emo_res = await db.execute(emo_query) - for emo in emo_res.scalars().all(): - emotion_map[emo.message_id] = emo + user: User = Depends(get_current_user), + service: ConversationService = Depends(get_conversation_service), +) -> ConversationListResponse: + """Retrieve paginated conversations with messages and emotion mappings.""" + return await service.get_conversations(user.id, page=page, limit=limit) - formatted_items = [_format_conversation(c, emotion_map) for c in convs] - total_pages = max(1, math.ceil(total_items / limit)) if total_items > 0 else 1 - has_more = (offset + len(convs)) < total_items - return { - "items": formatted_items, - "pagination": { - "page": page, - "limit": limit, - "total_items": total_items, - "total_pages": total_pages, - "has_more": has_more, - }, - "total": total_items, - "page": page, - "limit": limit, - "has_more": has_more, - } - - -@router.get("/today") +@router.get("/today", response_model=TodayConversationResponse) async def get_today_conversations( page: int = Query(1, ge=1, description="Page number for pagination"), limit: int = Query(10, ge=1, le=100, description="Items per page"), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: + user: User = Depends(get_current_user), + service: ConversationService = Depends(get_conversation_service), +) -> TodayConversationResponse: """Retrieve today's voice call sessions with local time formatting and backend pagination.""" - today_wib = get_wib_today() - start_utc, end_utc = get_wib_day_range_utc(today_wib) - - # Count total today conversations - count_query = ( - select(func.count()) - .select_from(Conversation) - .where( - Conversation.user_id == user.id, - Conversation.started_at >= start_utc, - Conversation.started_at <= end_utc, - ) - ) - total_res = await db.execute(count_query) - total_items = total_res.scalar() or 0 - - offset = (page - 1) * limit - query = ( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where( - Conversation.user_id == user.id, - Conversation.started_at >= start_utc, - Conversation.started_at <= end_utc, - ) - .order_by(Conversation.started_at.desc()) - .offset(offset) - .limit(limit) - ) - res = await db.execute(query) - convs = res.scalars().all() - - # Query emotion analysis for all messages in convs - message_ids = [m.id for c in convs for m in c.messages] - emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} - if message_ids: - emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) - emo_res = await db.execute(emo_query) - for emo in emo_res.scalars().all(): - emotion_map[emo.message_id] = emo - - # Query today's diary entry for cached emotion summary - diary_query = select(DiaryEntry).where(DiaryEntry.user_id == user.id, DiaryEntry.entry_date == today_wib) - res_diary = await db.execute(diary_query) - today_diary = res_diary.scalar_one_or_none() - - mood_tag = today_diary.mood_tag if today_diary and today_diary.mood_tag else "Tenang & Nyaman 🌿" - ai_insight = today_diary.ai_insight if today_diary and today_diary.ai_insight else "Pengguna merasa didengarkan dan tenang setelah berdialog bersama EVA." - - items = [] - for c in convs: - msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] - if not msgs: - continue + return await service.get_today_conversations(user.id, page=page, limit=limit) - time_formatted = _format_time_local(c.started_at) - c_emotions = [emotion_map[m.id].emotions for m in msgs if m.id in emotion_map and emotion_map[m.id].emotions] - if c_emotions: - breakdown = MLEmotionDetectorService.aggregate_session_emotions(c_emotions) - else: - breakdown = [ - {"label": "Ketenangan & Kedamaian", "emoji": "😌", "percent": 0.70, "color": "#4ECDC4"}, - {"label": "Bahagia & Puas", "emoji": "😃", "percent": 0.20, "color": "#FFE6A7"}, - {"label": "Tingkat Stres", "emoji": "😟", "percent": 0.10, "color": "#FF8B94"}, - ] - - analysis = { - "dominant_emotion": mood_tag, - "calm_score": "85%", - "stress_level": "Rendah", - "empathy_level": "Sangat Tinggi", - "ai_insight": ai_insight, - "emotions_breakdown": breakdown, - } - - transcript_items = [] - for m in msgs: - emo_tag, emo_emoji = _get_message_emotion(m, emotion_map) - transcript_items.append({ - "role": m.role, - "content": m.content, - "time": _format_time_local(m.created_at), - "emotionTag": emo_tag if m.role == "user" else None, - "emotionEmoji": emo_emoji if m.role == "user" else None, - }) - - items.append({ - "id": str(c.id), - "title": c.title or "Sesi Panggilan Suara EVA", - "is_title_generating": "$skeleton" in (c.title or ""), - "date": f"Hari ini, {time_formatted}", - "duration": _format_duration_str(c.started_at, c.last_message_at), - "moodTag": mood_tag, - "emotion_analysis": analysis, - "transcript": transcript_items, - }) - - return { - "items": items, - "total": total_items, - "page": page, - "limit": limit, - "has_more": (offset + len(items)) < total_items, - } - - -@router.get("/today/messages") +@router.get("/today/messages", response_model=TodayMessagesResponse) async def get_today_conversation_messages( page: int = Query(1, ge=1, description="Page number for pagination"), limit: int = Query(10, ge=1, le=100, description="Items per page"), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: + user: User = Depends(get_current_user), + service: ConversationService = Depends(get_conversation_service), +) -> TodayMessagesResponse: """Retrieve all messages from today's conversations ordered chronologically (Oldest First) with pagination.""" - today_wib = get_wib_today() - start_utc, end_utc = get_wib_day_range_utc(today_wib) - - # Query all today's conversations ordered chronologically (Oldest First) - conv_query = ( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where( - Conversation.user_id == user.id, - Conversation.started_at >= start_utc, - Conversation.started_at <= end_utc, - ) - .order_by(Conversation.started_at.asc()) - ) - conv_res = await db.execute(conv_query) - convs = conv_res.scalars().all() + return await service.get_today_messages(user.id, page=page, limit=limit) - # Query emotion analysis for all messages in convs - message_ids = [m.id for c in convs for m in c.messages] - emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} - if message_ids: - emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) - emo_res = await db.execute(emo_query) - for emo in emo_res.scalars().all(): - emotion_map[emo.message_id] = emo - all_msgs = [] - for c in convs: - sorted_msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] - sess_title = (c.title or "Sesi Panggilan Suara EVA").replace("$skeleton", "").strip() - for m in sorted_msgs: - if m.role in ("user", "assistant") and m.content and m.content.strip(): - is_user = m.role == "user" - emo_tag, emo_emoji = _get_message_emotion(m, emotion_map) - all_msgs.append({ - "id": str(m.id), - "role": m.role, - "isUser": is_user, - "content": m.content, - "text": m.content, - "time": _format_time_local(m.created_at or c.started_at), - "sessionTitle": sess_title, - "emotionTag": emo_tag if is_user else None, - "emotionEmoji": emo_emoji if is_user else None, - }) +@router.get("/{conversation_id}", response_model=ConversationDetailResponse) +async def get_conversation_by_id( + conversation_id: str, + service: ConversationService = Depends(get_conversation_service), +) -> ConversationDetailResponse: + """Retrieve details and messages of a specific conversation session.""" + return await service.get_conversation_by_id(conversation_id) - total_items = len(all_msgs) - total_pages = (total_items + limit - 1) // limit if total_items > 0 else 1 - offset = (page - 1) * limit - items = all_msgs[offset : offset + limit] - return { - "items": items, - "total_items": total_items, - "page": page, - "limit": limit, - "total_pages": total_pages, - } - - -@router.get("/{conversation_id}") -async def get_conversation_by_id(conversation_id: str, db: AsyncSession = Depends(get_db_session)) -> dict[str, Any]: - try: - c_uuid = uuid.UUID(conversation_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid conversation ID") - - query = ( - select(Conversation) - .options( - selectinload(Conversation.messages), - selectinload(Conversation.summaries), - ) - .where(Conversation.id == c_uuid) - ) - res = await db.execute(query) - conv = res.scalar_one_or_none() - - if not conv: - raise HTTPException(status_code=404, detail="Conversation not found") - - message_ids = [m.id for m in conv.messages] if conv.messages else [] - emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} - if message_ids: - emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) - emo_res = await db.execute(emo_query) - for emo in emo_res.scalars().all(): - emotion_map[emo.message_id] = emo - - return _format_conversation(conv, emotion_map) - - -@router.post("/{conversation_id}/messages") +@router.post("/{conversation_id}/messages", response_model=SentMessageResponse) async def send_message( - conversation_id: str, payload: SendMessageRequest, db: AsyncSession = Depends(get_db_session) -) -> dict[str, Any]: - try: - c_uuid = uuid.UUID(conversation_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid conversation ID") - - query = ( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where(Conversation.id == c_uuid) - ) - res = await db.execute(query) - conv = res.scalar_one_or_none() - - if not conv: - raise HTTPException(status_code=404, detail="Conversation not found") - - next_seq = len(conv.messages) + 1 - user_msg = Message( - conversation_id=conv.id, - role="user", - content=payload.content, - message_type=MessageType.TEXT, - sequence_number=next_seq, - ) - db.add(user_msg) - await db.commit() - await db.refresh(user_msg) - - if payload.content and payload.content.strip(): - try: - analysis_result = await MLEmotionDetectorService.predict_message_emotion(payload.content) - await MLEmotionDetectorService.save_emotion_to_db(user_msg.id, analysis_result, db) - logger.info(f"🧠 [CHAT EMOTION PERSISTED]: Saved emotion for message {user_msg.id}") - except Exception as emo_err: - logger.warning(f"⚠️ [CHAT EMOTION EXCEPTION]: Failed to save emotion analysis: {emo_err}") - - return { - "id": str(user_msg.id), - "sender": "user", - "text": user_msg.content, - "time": "Sekarang", - "isAudio": False, - } + conversation_id: str, + payload: SendMessageRequest, + service: ConversationService = Depends(get_conversation_service), +) -> SentMessageResponse: + """Send and persist a new message within a conversation.""" + return await service.send_message(conversation_id, payload) @router.get("/{conversation_id}/summary/stream") async def stream_conversation_summary( conversation_id: uuid.UUID, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -): + user: User = Depends(get_current_user), + service: SummaryStreamService = Depends(get_summary_stream_service), +) -> StreamingResponse: """Stream 2-3 sentence empathetic counseling summary token-by-token using SSE and persist upon completion.""" - query = ( - select(Conversation) - .options( - selectinload(Conversation.messages), - selectinload(Conversation.summaries), - ) - .where(Conversation.id == conversation_id, Conversation.user_id == user.id) - ) - res = await db.execute(query) - conv = res.scalar_one_or_none() - if not conv: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Percakapan tidak ditemukan", - ) - - # 1. If already persisted in DB, yield cached summary immediately - if conv.summaries and conv.summaries[-1].summary: - existing_summary = conv.summaries[-1].summary - - async def cached_stream(): - yield f"data: {json.dumps({'token': existing_summary})}\n\n" - yield f"data: {json.dumps({'done': True, 'summary': existing_summary})}\n\n" - - return StreamingResponse( - cached_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - # 2. Extract user and assistant messages for transcript - msgs = sorted(conv.messages, key=lambda m: m.created_at) if conv.messages else [] - user_assistant_msgs = [ - m for m in msgs if m.role in ("user", "assistant") and m.content and m.content.strip() - ] - - CRISIS_KEYWORDS = ( - "bunuh diri", "capek hidup", "mati saja", "ingin mati", "putus asa", - "menyakiti diri", "gantung diri", "akhiri hidup", "tak sanggup lagi", - "tidak sanggup hidup" - ) - transcript_text = "\n".join([f"{m.role.upper()}: {m.content}" for m in user_assistant_msgs]) - full_lower = transcript_text.lower() - has_crisis = any(kw in full_lower for kw in CRISIS_KEYWORDS) - - if not user_assistant_msgs: - fallback_summary = "Sesi percakapan curhat bersama EVA berjalan dengan tenang dan aman." - - async def empty_stream(): - yield f"data: {json.dumps({'token': fallback_summary})}\n\n" - yield f"data: {json.dumps({'done': True, 'summary': fallback_summary})}\n\n" - - return StreamingResponse( - empty_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - system_prompt = LLMMessage( - role="system", - content=( - "Anda adalah konselor psikologis profesional EVA AI. " - "Tugas Anda adalah membuat ringkasan percakapan konseling yang sangat ringkas, padat, dan menenangkan (tepat 2-3 kalimat Bahasa Indonesia).\n" - "Pedoman wajib:\n" - "1. Fokus pada esensi perasaan yang dicurahkan pengguna dan pendampingan reflektif dari EVA.\n" - "2. Gunakan nada bicara hangat, empatik, tenang, dan suportif.\n" - "3. DILARANG KERAS mengutip mentah kata-kata pengguna dalam tanda kutip.\n" - "4. Jika terdeteksi kondisi krisis berat, berikan narasi suportif yang menekankan ruang aman dan pentingnya pertolongan profesional.\n" - "5. Hasilkan HANYA teks ringkasan langsung tanpa judul, tanpa pembuka/penutup, dan tanpa format markdown." - ), - ) - user_prompt = LLMMessage( - role="user", - content=f"Berikut transkrip percakapan suara:\n\n{transcript_text}\n\nBuat ringkasan konseling 2-3 kalimat:", - ) - - async def generate_stream(): - full_tokens: list[str] = [] - try: - llm_provider = LLMFactory.get_provider() - async for token in llm_provider.stream_response([system_prompt, user_prompt]): - full_tokens.append(token) - yield f"data: {json.dumps({'token': token})}\n\n" - except Exception as e: - logger.error(f"⚠️ [SUMMARY STREAM LLM EXCEPTION]: {e}") - fallback_text = ( - "Pengguna meluangkan waktu untuk mengekspresikan beban emosional yang berat dalam sesi ini. " - "EVA hadir mendampingi secara suportif, memberikan ruang aman untuk bercerita, " - "dan menegaskan bahwa setiap perasaan berharga serta bantuan selalu tersedia." - if has_crisis - else "Pengguna berbagi refleksi harian dan tantangan perasaan secara terbuka. " - "EVA memberikan tanggapan yang menenangkan serta ruang aman untuk meredakan ketegangan." - ) - if not full_tokens: - yield f"data: {json.dumps({'token': fallback_text})}\n\n" - full_tokens.append(fallback_text) - - full_summary = "".join(full_tokens).strip() - # Clean any stray wrapping quotes - if full_summary.startswith('"') and full_summary.endswith('"') and len(full_summary) > 2: - full_summary = full_summary[1:-1].strip() - - # Persist summary to database so subsequent calls are instantaneous - if full_summary: - try: - async with AsyncSessionLocal() as db_stream: - new_summary = ConversationSummary( - conversation_id=conversation_id, - summary=full_summary, - ) - db_stream.add(new_summary) - await db_stream.commit() - logger.info(f"✅ [SUMMARY STREAM PERSISTED]: Conversation {conversation_id}") - except Exception as db_err: - logger.error(f"⚠️ [FAILED TO PERSIST STREAM SUMMARY]: {db_err}") - - dominant_emotion = "Krisis / Sangat Tertekan" if has_crisis else None - dominant_emoji = "🆘" if has_crisis else None - - yield f"data: {json.dumps({'done': True, 'summary': full_summary, 'dominant_emotion': dominant_emotion, 'dominant_emoji': dominant_emoji})}\n\n" - - return StreamingResponse( - generate_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - + return await service.stream_summary(conversation_id, user.id) diff --git a/apps/backend/api/app/api/routes/dass.py b/apps/backend/api/app/api/routes/dass.py index c0c90ad..f097db1 100644 --- a/apps/backend/api/app/api/routes/dass.py +++ b/apps/backend/api/app/api/routes/dass.py @@ -1,137 +1,52 @@ import logging import uuid -from typing import Any -from fastapi import APIRouter, Depends, Header, HTTPException, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from fastapi import APIRouter, Depends -from app.core.security import decode_access_token -from app.core.tz import get_wib_day_range_utc, get_wib_today -from ai.services.dass_extraction_service import DASSExtractionService -from app.db.session import get_db_session -from app.models.conversation import Conversation, Message +from app.api.dependencies import get_current_user, get_dass_service from app.models.user import User from app.schemas.dass import DASSAssessmentResponse, DASSAssessmentUpdate -from app.services.dass_service import DASSService +from app.services.dass import DASSService logger = logging.getLogger(__name__) router = APIRouter(prefix="/dass", tags=["DASS-21"]) -async def _get_current_user( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> User: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - @router.get("/today", response_model=DASSAssessmentResponse) async def get_today_dass( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> Any: - """Mengambil status dan butir asesmen DASS-21 hari ini untuk pengguna.""" - today_wib = get_wib_today() - return await DASSService.get_today_assessment(user.id, today_wib, db) + user: User = Depends(get_current_user), + service: DASSService = Depends(get_dass_service), +) -> DASSAssessmentResponse: + """Retrieve today's DASS-21 assessment status and items for the authenticated user.""" + return await service.get_today_assessment(user.id) @router.put("/today", response_model=DASSAssessmentResponse) async def update_today_dass( payload: DASSAssessmentUpdate, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> Any: - """Menerima koreksi skor butir DASS-21 dari pengguna dan melakukan rekalkulasi skor.""" - today_wib = get_wib_today() - return await DASSService.update_user_assessment(user.id, today_wib, payload.items, db) + user: User = Depends(get_current_user), + service: DASSService = Depends(get_dass_service), +) -> DASSAssessmentResponse: + """Receive user item score corrections and recalculate subscale severity scores.""" + return await service.update_user_assessment(user.id, payload.items) @router.put("/{assessment_id}", response_model=DASSAssessmentResponse) async def update_dass_by_id( assessment_id: uuid.UUID, payload: DASSAssessmentUpdate, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> Any: - """Menerima koreksi skor butir DASS-21 berdasarkan ID asesmen spesifik.""" - today_wib = get_wib_today() - return await DASSService.update_user_assessment(user.id, today_wib, payload.items, db) + user: User = Depends(get_current_user), + service: DASSService = Depends(get_dass_service), +) -> DASSAssessmentResponse: + """Receive user score corrections for a specific assessment ID.""" + return await service.update_user_assessment(user.id, payload.items, assessment_id=assessment_id) @router.post("/extract-today", response_model=DASSAssessmentResponse) async def extract_today_dass( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> Any: - """Memicu analisis ekstraksi AI 21 butir DASS-21 dari seluruh transkrip percakapan hari ini.""" - today_wib = get_wib_today() - start_utc, end_utc = get_wib_day_range_utc(today_wib) - - # Ambil percakapan hari ini - conv_stmt = ( - select(Conversation) - .where( - Conversation.user_id == user.id, - Conversation.created_at >= start_utc, - Conversation.created_at <= end_utc, - ) - .options(selectinload(Conversation.messages)) - ) - conv_res = await db.execute(conv_stmt) - conversations = conv_res.scalars().all() - - formatted_msgs = [] - latest_conv_id = None - for c in conversations: - latest_conv_id = c.id - for m in sorted(c.messages, key=lambda x: x.created_at): - if m.content and m.content.strip(): - formatted_msgs.append(f"{m.role.upper()}: {m.content}") - - transcript = "\n".join(formatted_msgs) - extractor = DASSExtractionService() - extracted_items = await extractor.extract_from_transcript(transcript) - - saved_record = await DASSService.save_or_update_extracted( - user_id=user.id, - assessed_date=today_wib, - extracted_items=extracted_items, - conversation_id=latest_conv_id, - db=db, - ) - return DASSAssessmentResponse.model_validate(saved_record) + user: User = Depends(get_current_user), + service: DASSService = Depends(get_dass_service), +) -> DASSAssessmentResponse: + """Trigger AI extraction analysis of 21 DASS items from all conversation transcripts today.""" + return await service.extract_today_assessment(user.id) diff --git a/apps/backend/api/app/api/routes/diaries.py b/apps/backend/api/app/api/routes/diaries.py index 2136063..a1e3bbe 100644 --- a/apps/backend/api/app/api/routes/diaries.py +++ b/apps/backend/api/app/api/routes/diaries.py @@ -1,406 +1,74 @@ -import uuid -from datetime import date import logging -from typing import Any -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status -from pydantic import BaseModel -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from fastapi import APIRouter, Depends, Query -from app.core.security import decode_access_token -from app.core.tz import get_wib_day_range_utc, get_wib_today, to_wib -from app.db.session import get_db_session -from app.models.conversation import Conversation -from app.models.diary import DiaryEntry +from app.api.dependencies import get_current_user, get_diary_service from app.models.user import User -from app.services.diary_generator import DiaryGeneratorService +from app.schemas.diary import ( + CreateDiaryRequest, + DeleteDiaryResponse, + DiaryEntryResponse, +) +from app.services.diary import DiaryService logger = logging.getLogger(__name__) router = APIRouter(prefix="/diaries", tags=["Diaries"]) -class CreateDiaryRequest(BaseModel): - content: str - mood_tag: str | None = None - - -async def _get_current_user( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> User: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - -def _format_diary_entry(d: DiaryEntry | None) -> dict[str, Any]: - if not d: - return {} - raw_events = d.important_events if isinstance(d.important_events, list) else [] - events = raw_events if raw_events else ["Sesi refleksi harian tercatat dalam sistem EVA."] - - scores = d.mental_health_scores or {} - dep = float(scores.get("depression", 0.25)) - anx = float(scores.get("anxiety", 0.45)) - strs = float(scores.get("stress", 0.15)) - - raw_breakdown = [ - {"name": "fear", "label": "Takut / Gelisah", "emoji": "😨", "percent": max(0.05, anx), "color": "#6C63FF"}, - {"name": "sadness", "label": "Sedih / Haru", "emoji": "😔", "percent": max(0.05, dep), "color": "#8B93FF"}, - {"name": "netral", "label": "Netral", "emoji": "😐", "percent": 0.15, "color": "#A7E6FF"}, - {"name": "stress", "label": "Stres / Tertekan", "emoji": "💥", "percent": max(0.05, strs), "color": "#FF8A80"}, - {"name": "happy", "label": "Bahagia", "emoji": "😃", "percent": 0.10, "color": "#FFE6A7"}, - {"name": "surprise", "label": "Terkejut", "emoji": "😲", "percent": 0.03, "color": "#C3B8FF"}, - {"name": "anger", "label": "Marah", "emoji": "😡", "percent": 0.02, "color": "#FFB6C1"}, - ] - tot = sum(item["percent"] for item in raw_breakdown) - emotions_breakdown = [ - { - **item, - "percent": round(item["percent"] / tot, 2), - } - for item in raw_breakdown - ] - - return { - "id": str(d.id), - "title": d.title or "Refleksi Harian EVA", - "date": d.entry_date.strftime("%d %B %Y") if isinstance(d.entry_date, date) else str(d.entry_date), - "sessionCount": 0, - "lastSessionTime": "-", - "moodTag": d.mood_tag or "Netral", - "moodEmoji": d.mood_emoji or "😌", - "summary": d.summary or "Catatan harian perkembangan emosional bersama EVA.", - "riskWarning": { - "detected": True, - "type": "High Risk / Krisis", - "title": "PERINGATAN KRISIS EMOSIONAL", - "level": "RISIKO TINGGI", - "message": "Sistem EVA mendeteksi akumulasi indikasi krisis emosional tinggi dan stres berat pada percakapan hari ini. Protokol keselamatan aktif untuk rujukan darurat 119 ext 8.", - } if ("Darurat" in (d.mood_tag or "") or "Stres" in (d.mood_tag or "") or "Cemas" in (d.mood_tag or "") or "Sedih" in (d.mood_tag or "")) else None, - "aiInsight": d.ai_insight or "Analisis AI menunjukkan kondisi stabil.", - "importantEvents": events, - "emotionalReflection": d.emotional_reflection or "Merasa tenang setelah refleksi.", - "mentalHealthScores": d.mental_health_scores or {}, - "emotionsBreakdown": emotions_breakdown, - "sessions": [], - } - - -def _calculate_session_emotions( - transcripts: list[dict[str, Any]], - session_title: str, - default_breakdown: list[dict[str, Any]], -) -> list[dict[str, Any]]: - combined_text = (session_title + " " + " ".join(t["text"] for t in transcripts if t.get("isUser"))).lower() - - anx_keywords = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup"] - dep_keywords = ["bunuh diri", "depresi", "sedih", "sendiri", "kehilangan", "hampa", "nangis", "menangis", "terpuruk"] - str_keywords = ["stres", "capek", "lelah", "beban", "berat", "penat", "tugas", "kerja"] - pos_keywords = ["lega", "senang", "terima kasih", "enakan", "tenang", "nyaman", "tidur", "santai", "baik"] - - anx_count = sum(1 for kw in anx_keywords if kw in combined_text) - dep_count = sum(1 for kw in dep_keywords if kw in combined_text) - str_count = sum(1 for kw in str_keywords if kw in combined_text) - pos_count = sum(1 for kw in pos_keywords if kw in combined_text) - - if anx_count == 0 and dep_count == 0 and str_count == 0 and pos_count == 0: - return default_breakdown - - raw = [ - {"name": "fear", "label": "Takut / Gelisah", "emoji": "😨", "percent": max(0.04, anx_count * 0.25), "color": "#6C63FF"}, - {"name": "sadness", "label": "Sedih / Haru", "emoji": "😔", "percent": max(0.04, dep_count * 0.25), "color": "#8B93FF"}, - {"name": "netral", "label": "Netral", "emoji": "😐", "percent": 0.08, "color": "#A7E6FF"}, - {"name": "stress", "label": "Stres / Tertekan", "emoji": "💥", "percent": max(0.04, str_count * 0.20), "color": "#FF8A80"}, - {"name": "happy", "label": "Bahagia", "emoji": "😃", "percent": max(0.03, pos_count * 0.15), "color": "#FFE6A7"}, - {"name": "surprise", "label": "Terkejut", "emoji": "😲", "percent": 0.02, "color": "#C3B8FF"}, - {"name": "anger", "label": "Marah", "emoji": "😡", "percent": 0.01, "color": "#FFB6C1"}, - ] - tot = sum(item["percent"] for item in raw) - return [ - {**item, "percent": round(item["percent"] / tot, 2)} - for item in raw - ] - - -def _analyze_message_emotion(text: str, is_user: bool) -> tuple[str, str]: - """Dynamically determine emotion tag and emoji from individual message content.""" - if not is_user: - return "empathy", "💙" - - t_low = text.lower() - # 1. Cemas / Takut (Fear / Anxiety) - anx_words = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup", "takutnya", "bingung"] - # 2. Sedih / Terpuruk (Sadness / Depression) - dep_words = ["sedih", "nangis", "menangis", "hampa", "sendiri", "kehilangan", "terpuruk", "putus asa", "kecewa", "patah hati", "bunuh diri"] - # 3. Stres / Beban / Lelah (Stress / Exhaustion) - str_words = ["stres", "capek", "lelah", "beban", "berat", "penat", "pusing", "mumet", "tekanan", "tugas", "deadline", "kerjaan"] - # 4. Marah / Kesal (Anger) - ang_words = ["marah", "kesal", "jengkel", "benci", "emosi", "sebal", "kesel"] - # 5. Lega / Tenang / Positif (Relief / Joy) - pos_words = ["lega", "senang", "bahagia", "terima kasih", "makasih", "enakan", "tenang", "nyaman", "santai", "alhamdulillah", "syukurlah", "baik"] - - if any(w in t_low for w in anx_words): - return "fear (78%)", "😨" - if any(w in t_low for w in dep_words): - return "sadness (82%)", "😔" - if any(w in t_low for w in str_words): - return "stress (75%)", "💥" - if any(w in t_low for w in ang_words): - return "anger (70%)", "😡" - if any(w in t_low for w in pos_words): - return "calm (85%)", "😌" - - return "netral (60%)", "😐" - - -async def _attach_sessions(formatted: dict[str, Any], entry: DiaryEntry, db: AsyncSession) -> dict[str, Any]: - """Populate 'sessions' with real conversation transcripts for the diary's date.""" - if not isinstance(entry.entry_date, date): - return formatted - - start_utc, end_utc = get_wib_day_range_utc(entry.entry_date) - conv_query = ( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where( - Conversation.user_id == entry.user_id, - Conversation.started_at >= start_utc, - Conversation.started_at <= end_utc, - ) - .order_by(Conversation.started_at.asc()) - ) - conv_res = await db.execute(conv_query) - convs = conv_res.scalars().all() - - sessions_data = [] - for c in convs: - sorted_msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] - sess_transcripts = [] - for m in sorted_msgs: - if m.role in ("user", "assistant") and m.content: - msg_t = to_wib(m.created_at) - emo_tag, emo_emoji = _analyze_message_emotion(m.content, m.role == "user") - sess_transcripts.append({ - "isUser": m.role == "user", - "time": msg_t.strftime("%H:%M") if msg_t else "09:00", - "text": m.content, - "emotionTag": emo_tag, - "emotionEmoji": emo_emoji, - }) - - # Skip empty sessions without user/assistant dialogue - if not sess_transcripts: - continue - - c_t = to_wib(c.started_at) - time_str = c_t.strftime("%H:%M") if c_t else "09:00" - title = (c.title or "").replace("$skeleton", "").strip() - if not title: - title = f"Sesi #{len(sessions_data) + 1} Percakapan Suara" - - sess_emotions = _calculate_session_emotions( - sess_transcripts, - title, - formatted.get("emotionsBreakdown") or [], - ) - - sessions_data.append({ - "id": str(c.id), - "title": title, - "time": time_str, - "moodTag": entry.mood_tag or "Netral", - "moodEmoji": entry.mood_emoji or "😌", - "emotionsBreakdown": sess_emotions, - "transcripts": sess_transcripts, - }) - - formatted["sessions"] = sessions_data - formatted["sessionCount"] = len(sessions_data) - if sessions_data: - formatted["lastSessionTime"] = sessions_data[-1]["time"] - else: - formatted["lastSessionTime"] = "-" - return formatted - - -@router.get("") +@router.get("", response_model=list[DiaryEntryResponse]) async def get_diaries( mood: str | None = Query(None), search: str | None = Query(None), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> list[dict[str, Any]]: - query = select(DiaryEntry).where(DiaryEntry.user_id == user.id).order_by(DiaryEntry.entry_date.desc()) - res = await db.execute(query) - entries = res.scalars().all() + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> list[DiaryEntryResponse]: + """Retrieve all diary entries for authenticated user with optional mood and search filters.""" + return await service.get_diaries(user.id, mood=mood, search=search) - if not entries: - return [] - formatted = [] - for e in entries: - if e is not None: - f = _format_diary_entry(e) - f = await _attach_sessions(f, e, db) - formatted.append(f) - - if mood and mood != "Semua": - clean_mood = mood.lower() - formatted = [f for f in formatted if clean_mood in f["moodTag"].lower()] - - if search: - clean_q = search.lower() - formatted = [ - f for f in formatted - if clean_q in f["title"].lower() or clean_q in f["summary"].lower() - ] - - return formatted - - -@router.get("/today") +@router.get("/today", response_model=DiaryEntryResponse) async def get_today_diary( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - today = get_wib_today() - query = select(DiaryEntry).where(DiaryEntry.user_id == user.id, DiaryEntry.entry_date == today) - res = await db.execute(query) - entry = res.scalar_one_or_none() - - if not entry: - entry = await DiaryGeneratorService.generate_today_diary(user.id, db) + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> DiaryEntryResponse: + """Retrieve today's diary entry, auto-generating via LLM if not yet present.""" + return await service.get_today_diary(user.id) - if not entry: - return {"id": None, "message": "Belum ada jurnal untuk hari ini."} - formatted = _format_diary_entry(entry) - formatted = await _attach_sessions(formatted, entry, db) - return formatted - - -@router.get("/{diary_id}") +@router.get("/{diary_id}", response_model=DiaryEntryResponse) async def get_diary_by_id( diary_id: str, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - try: - d_uuid = uuid.UUID(diary_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid diary ID") - - query = select(DiaryEntry).where( - DiaryEntry.id == d_uuid, - DiaryEntry.user_id == user.id, - ) - res = await db.execute(query) - entry = res.scalar_one_or_none() - - if not entry: - raise HTTPException(status_code=404, detail="Diary entry not found") + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> DiaryEntryResponse: + """Retrieve a single diary entry by ID with ownership verification.""" + return await service.get_diary_by_id(user.id, diary_id) - formatted = _format_diary_entry(entry) - formatted = await _attach_sessions(formatted, entry, db) - return formatted - -@router.post("/generate") +@router.post("/generate", response_model=DiaryEntryResponse) async def generate_today_diary( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - entry = await DiaryGeneratorService.generate_today_diary(user.id, db) - return _format_diary_entry(entry) + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> DiaryEntryResponse: + """Force re-synthesis of today's diary from all conversation transcripts.""" + return await service.generate_today_diary(user.id) -@router.post("") +@router.post("", response_model=DiaryEntryResponse) async def create_diary( payload: CreateDiaryRequest, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - today = get_wib_today() - - query = select(DiaryEntry).where(DiaryEntry.user_id == user.id, DiaryEntry.entry_date == today) - res = await db.execute(query) - entry = res.scalar_one_or_none() - - if entry: - entry.summary += f"\n- {payload.content}" - entry.content += f"\n- {payload.content}" - if payload.mood_tag: - entry.mood_tag = payload.mood_tag - else: - entry = DiaryEntry( - user_id=user.id, - entry_date=today, - title="Refleksi Catatan Baru", - summary=payload.content, - content=payload.content, - mood_tag=payload.mood_tag or "Netral", - mood_emoji="😌", - ai_insight="Catatan refleksi berhasil disimpan.", - emotional_reflection="Merasa lega setelah menulis catatan.", - important_events=[f"[Sesi #1] {payload.content[:50]}"], - ) - db.add(entry) - - await db.commit() - await db.refresh(entry) + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> DiaryEntryResponse: + """Create or append today's custom diary entry reflection.""" + return await service.create_diary(user.id, payload) - return _format_diary_entry(entry) - - -@router.delete("/{diary_id}") -async def delete_diary(diary_id: str, db: AsyncSession = Depends(get_db_session)) -> dict[str, str]: - try: - d_uuid = uuid.UUID(diary_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid diary ID") - - query = select(DiaryEntry).where(DiaryEntry.id == d_uuid) - res = await db.execute(query) - entry = res.scalar_one_or_none() - - if not entry: - raise HTTPException(status_code=404, detail="Diary entry not found") - - await db.delete(entry) - await db.commit() - return {"message": "Diary entry deleted successfully"} +@router.delete("/{diary_id}", response_model=DeleteDiaryResponse) +async def delete_diary( + diary_id: str, + service: DiaryService = Depends(get_diary_service), +) -> DeleteDiaryResponse: + """Delete a diary entry by ID.""" + return await service.delete_diary(diary_id) diff --git a/apps/backend/api/app/api/routes/health.py b/apps/backend/api/app/api/routes/health.py index 225c9bb..2b6591a 100644 --- a/apps/backend/api/app/api/routes/health.py +++ b/apps/backend/api/app/api/routes/health.py @@ -1,6 +1,6 @@ from app.api.dependencies import get_redis_client from app.schemas.health import HealthCheckResponse -from app.services.status_service import SystemStatusService +from app.services.status import SystemStatusService from fastapi import APIRouter, Depends from redis.asyncio import Redis from shared.config import settings diff --git a/apps/backend/api/app/api/routes/recommendations.py b/apps/backend/api/app/api/routes/recommendations.py index 3bc33ae..df23506 100644 --- a/apps/backend/api/app/api/routes/recommendations.py +++ b/apps/backend/api/app/api/routes/recommendations.py @@ -1,458 +1,45 @@ -from datetime import date import logging -from typing import Any -import uuid -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status -from sqlalchemy import desc, func, select -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends, Query -from app.core.security import decode_access_token -from app.core.tz import get_wib_today -from app.db.session import get_db_session -from app.models.coping_activity import CopingActivity, UserActivityCompletion -from app.models.dass import DASSAssessment -from app.models.diary import DiaryEntry -from app.models.enums import SafetyEventStatus -from app.models.recommendation import RecommendationItem -from app.models.safety import SafetyEvent +from app.api.dependencies import get_current_user, get_recommendation_service from app.models.user import User +from app.schemas.recommendation import ( + CompleteActivityResponse, + CopingActivityResponse, + TodayRecommendationResponse, +) +from app.services.recommendation import RecommendationService logger = logging.getLogger(__name__) router = APIRouter(prefix="/recommendations", tags=["Recommendations"]) -async def _get_current_user( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> User: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - -@router.get("/today") +@router.get("/today", response_model=TodayRecommendationResponse) async def get_today_recommendation( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - """Retrieve today's tailored coping activities & condition guidance based on - DASS-21 psychological assessment, Safety Crisis Event, and Diary Mental Health Scores. - """ - today_wib = get_wib_today() - - # 1. Check open Crisis / Suicide Risk - is_crisis = False - crisis_hotline = "Kemenkes 119 ext 8 / LISA 0811-3855-472" - crisis_guidance = ( - "Layanan konseling mandiri AI dibatasi saat terindikasi krisis emosional tinggi. " - "Keselamatanmu adalah hal yang paling berharga. Tolong segera hubungi tenaga profesional atau orang terdekat sekarang juga." - ) - - safety_query = ( - select(SafetyEvent) - .where( - SafetyEvent.user_id == user.id, - SafetyEvent.status == SafetyEventStatus.OPEN, - SafetyEvent.severity.in_(["high", "critical"]), - ) - .order_by(desc(SafetyEvent.detected_at)) - .limit(1) - ) - res_safety = await db.execute(safety_query) - open_safety_event = res_safety.scalars().first() - - # 2. Fetch today's or most recent DASS-21 assessment - dass_query = ( - select(DASSAssessment) - .where(DASSAssessment.user_id == user.id) - .order_by(desc(DASSAssessment.assessed_date)) - .limit(1) - ) - res_dass = await db.execute(dass_query) - latest_dass = res_dass.scalars().first() - - # 3. Fetch today's or recent diary entry - diary_query = ( - select(DiaryEntry) - .where(DiaryEntry.user_id == user.id, DiaryEntry.entry_date == today_wib) - .order_by(desc(DiaryEntry.created_at)) - ) - res_diary = await db.execute(diary_query) - today_diary = res_diary.scalars().first() - - if not today_diary: - recent_query = ( - select(DiaryEntry) - .where(DiaryEntry.user_id == user.id) - .order_by(desc(DiaryEntry.entry_date)) - .limit(1) - ) - res_recent = await db.execute(recent_query) - today_diary = res_recent.scalars().first() - - # Check for suicide / self-harm indication in DASS item 21 - suicide_dass_detected = False - if latest_dass and latest_dass.items: - for it in latest_dass.items: - if it.get("item_id") == 21 and int(it.get("score", 0)) >= 2: - suicide_dass_detected = True - break - - if open_safety_event or suicide_dass_detected: - is_crisis = True - dominant_condition = "crisis" - severity_level = "Krisis / Darurat" - condition_label = "Protokol Krisis & Bantuan Darurat" - condition_reason = ( - "Terdeteksi indikasi krisis emosional atau pemikiran menyakiti diri. " - "Sesuai pedoman klinis WHO (Knowledge K013), prioritas utama saat ini adalah keselamatan dan pendampingan darurat." - ) - else: - # Default general - dominant_condition = "general" - severity_level = "Normal" - condition_label = "Fokus: Relaksasi & Perawatan Diri" - condition_reason = "Menjaga ritme emosi dan ketenangan pikiran harian dengan latihan mandiri sederhana." - - # Prioritas 1: Asesmen DASS-21 - if latest_dass and (latest_dass.stress_score > 0 or latest_dass.anxiety_score > 0 or latest_dass.depression_score > 0): - s_score = latest_dass.stress_score - a_score = latest_dass.anxiety_score - d_score = latest_dass.depression_score - - if a_score >= s_score and a_score >= d_score and a_score >= 8: - dominant_condition = "anxiety" - sev = latest_dass.anxiety_severity - if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: - severity_level = "Berat" - condition_label = "Kecemasan Berat: Grounding Somatis" - condition_reason = ( - "Terdeteksi kecemasan tinggi atau overthinking intens. " - "Teknik Grounding somatis menstabilkan respon panik (Knowledge K002)." - ) - elif sev in ["Moderate", "Sedang"]: - severity_level = "Sedang" - condition_label = "Kecemasan Sedang: Notice & Name" - condition_reason = ( - "Terdeteksi kecemasan sedang & overthinking. " - "Metode 'Notice and Name' terbukti membantu melepaskan diri dari pikiran menjebak (Knowledge K003)." - ) - else: - severity_level = "Ringan" - condition_label = "Kecemasan Ringan: Stabilisasi Napas" - condition_reason = ( - "Terindikasi ketegangan cemas ringan. " - "Latihan pernapasan lambat teratur membantu memperlambat respon fisiologis cemas (Knowledge K009)." - ) - - elif s_score >= d_score and s_score >= 15: - dominant_condition = "stress" - sev = latest_dass.stress_severity - if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: - severity_level = "Berat" - condition_label = "Stres Berat: Grounding Badai Emosi" - condition_reason = ( - "Terdeteksi badai emosional dan stres tingkat berat. " - "Teknik Grounding intensif membantu menstabilkan diri dari pikiran yang membebani (Knowledge K002)." - ) - elif sev in ["Moderate", "Sedang"]: - severity_level = "Sedang" - condition_label = "Stres Sedang: PMR & Solusi Bertahap" - condition_reason = ( - "Terdeteksi stres tingkat sedang. " - "Relaksasi otot progresif (PMR) dan metode Stop-Think-Go meredakan ketegangan (Knowledge K002 & K011)." - ) - else: - severity_level = "Ringan" - condition_label = "Stres Ringan: Pernapasan 4-7-8" - condition_reason = ( - "Terdeteksi beban stres ringan. " - "Latihan napas 4-7-8 dan jeda digital membantu menenangkan pikiran (Knowledge K001)." - ) - - elif d_score >= 10: - dominant_condition = "depression" - sev = latest_dass.depression_severity - if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: - severity_level = "Berat" - condition_label = "Suasana Hati Sangat Rendah: Langkah Mikro" - condition_reason = ( - "Terindikasi keputusasaan atau devaluasi diri berat. " - "Fokuslah pada 1 langkah terkecil dan bicarakan dengan orang terdekat (Knowledge K005 & K010)." - ) - elif sev in ["Moderate", "Sedang"]: - severity_level = "Sedang" - condition_label = "Depresi Sedang: Aktivasi Perilaku" - condition_reason = ( - "Terindikasi kelelahan emosional dan anhedonia. " - "Aktivasi perilaku metode tangga 'Changing My Actions' memutus siklus murung (Knowledge K010)." - ) - else: - severity_level = "Ringan" - condition_label = "Suasana Hati Rendah: Refleksi Syukur" - condition_reason = ( - "Terindikasi suasana hati murung ringan. " - "Melatih rasa syukur dan pengenalan emosi membantu mengimbangi bias negatif (Knowledge K005 & K008)." - ) - - # Prioritas 2: Jika DASS belum terisi, periksa Diary - elif today_diary and today_diary.mental_health_scores: - scores = today_diary.mental_health_scores - stress = float(scores.get("stress", 0.0)) - anxiety = float(scores.get("anxiety", 0.0)) - depression = float(scores.get("depression", 0.0)) - - max_score = max(stress, anxiety, depression) - if max_score >= 0.25: - if anxiety >= stress and anxiety >= depression: - dominant_condition = "anxiety" - severity_level = "Sedang" if anxiety >= 0.6 else "Ringan" - condition_label = f"Kecemasan {severity_level}: Relaksasi Napas" - condition_reason = "Terindikasi kecemasan dari dialog terakhir. Latihan pernapasan terbukti menenangkan saraf (Knowledge K009)." - elif stress >= depression: - dominant_condition = "stress" - severity_level = "Sedang" if stress >= 0.6 else "Ringan" - condition_label = f"Stres {severity_level}: Pelepasan Beban" - condition_reason = "Terindikasi beban stres dari dialog terakhir. Teknik relaksasi membantu mengembalikan ketenangan (Knowledge K001)." - else: - dominant_condition = "depression" - severity_level = "Sedang" if depression >= 0.6 else "Ringan" - condition_label = f"Pemulihan Energi {severity_level}: Aktivasi" - condition_reason = "Terindikasi kelelahan emosional. Aktivitas ringan di luar ruangan membantu membangkitkan suasana hati (Knowledge K010)." - - # Query Coping Activities - query_target = ( - select(CopingActivity) - .where( - CopingActivity.is_active.is_(True), - CopingActivity.target_condition == dominant_condition, - ) - .order_by(CopingActivity.created_at.asc()) - ) - res_target = await db.execute(query_target) - target_activities = res_target.scalars().all() - - query_general = ( - select(CopingActivity) - .where( - CopingActivity.is_active.is_(True), - CopingActivity.target_condition != dominant_condition, - ) - .order_by(CopingActivity.created_at.asc()) - ) - res_general = await db.execute(query_general) - general_activities = res_general.scalars().all() + user: User = Depends(get_current_user), + service: RecommendationService = Depends(get_recommendation_service), +) -> TodayRecommendationResponse: + """Retrieve today's tailored coping activities & guidance based on DASS-21, safety crisis, and diary scores.""" + return await service.get_today_recommendation(user.id) - all_sorted_activities = list(target_activities) + list(general_activities) - # Check user completions for today - completion_query = select(UserActivityCompletion.activity_id).where( - UserActivityCompletion.user_id == user.id, - UserActivityCompletion.completed_date == today_wib, - ) - res_comp = await db.execute(completion_query) - completed_ids = set(res_comp.scalars().all()) - - # Pick the primary activity (prefer incomplete matching activity) - primary_activity: CopingActivity | None = None - for act in target_activities: - if act.id not in completed_ids: - primary_activity = act - break - if not primary_activity and target_activities: - primary_activity = target_activities[0] - if not primary_activity and all_sorted_activities: - primary_activity = all_sorted_activities[0] - - def _map_act(act: CopingActivity) -> dict[str, Any]: - return { - "id": str(act.id), - "title": act.title, - "category": act.category, - "targetCondition": act.target_condition, - "duration": act.duration, - "difficulty": act.difficulty, - "level": act.difficulty, - "description": act.description, - "instructions": act.instructions or [], - "rationale": act.rationale or "", - "iconName": act.icon_name or "spa", - "isCompleted": act.id in completed_ids, - } - - all_recommendations_mapped = [_map_act(a) for a in all_sorted_activities] - - primary_mapped = _map_act(primary_activity) if primary_activity else { - "id": "default-1", - "title": "Latihan Pernapasan 4-7-8", - "category": "Mindfulness", - "targetCondition": dominant_condition, - "duration": "5 Menit", - "difficulty": "Pemula", - "level": "Pemula", - "description": "Teknik pernapasan sederhana untuk menenangkan sistem saraf.", - "instructions": [ - "Tarik napas dalam 4 detik.", - "Tahan napas 7 detik.", - "Hembuskan napas perlahan 8 detik.", - ], - "rationale": "Mengaktifkan saraf parasimpatik untuk meredakan ketegangan tubuh.", - "iconName": "spa", - "isCompleted": False, - } - - alternatives_mapped = [ - _map_act(a) - for a in all_sorted_activities - if primary_activity and a.id != primary_activity.id - ] - - return { - "targetCondition": dominant_condition, - "conditionLabel": condition_label, - "conditionReason": condition_reason, - "severityLevel": severity_level, - "isCrisis": is_crisis, - "crisisHotline": crisis_hotline, - "crisisGuidance": crisis_guidance, - "todayActivity": primary_mapped, - "completedTodayCount": len(completed_ids), - "alternatives": alternatives_mapped, - "allRecommendations": all_recommendations_mapped, - } - - -@router.get("") +@router.get("", response_model=list[CopingActivityResponse]) async def get_recommendations( condition: str | None = Query(None, description="Filter by condition: stress, anxiety, depression, general, crisis"), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> list[dict[str, Any]]: + user: User = Depends(get_current_user), + service: RecommendationService = Depends(get_recommendation_service), +) -> list[CopingActivityResponse]: """Get all available recommendations / coping activities sorted by condition.""" - today_wib = get_wib_today() - - query = select(CopingActivity).where(CopingActivity.is_active.is_(True)) - if condition: - query = query.where(CopingActivity.target_condition == condition.lower().strip()) - query = query.order_by(CopingActivity.created_at.asc()) - - res = await db.execute(query) - coping_items = res.scalars().all() + return await service.get_recommendations(user.id, condition=condition) - comp_query = select(UserActivityCompletion.activity_id).where( - UserActivityCompletion.user_id == user.id, - UserActivityCompletion.completed_date == today_wib, - ) - res_comp = await db.execute(comp_query) - completed_ids = set(res_comp.scalars().all()) - return [ - { - "id": str(r.id), - "title": r.title, - "category": r.category, - "targetCondition": r.target_condition, - "duration": r.duration, - "difficulty": r.difficulty, - "level": r.difficulty, - "description": r.description, - "instructions": r.instructions or [], - "rationale": r.rationale or "", - "iconName": r.icon_name or "spa", - "isCompleted": r.id in completed_ids, - } - for r in coping_items - ] - - -@router.post("/{recommendation_id}/complete") +@router.post("/{recommendation_id}/complete", response_model=CompleteActivityResponse) async def mark_recommendation_completed( recommendation_id: str, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: + user: User = Depends(get_current_user), + service: RecommendationService = Depends(get_recommendation_service), +) -> CompleteActivityResponse: """Mark a recommendation or coping activity as completed for today.""" - try: - r_uuid = uuid.UUID(recommendation_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid recommendation ID") - - today_wib = get_wib_today() - - # 1. Check if recommendation_id matches CopingActivity - query_act = select(CopingActivity).where(CopingActivity.id == r_uuid) - res_act = await db.execute(query_act) - activity = res_act.scalar_one_or_none() - - if activity: - # Check if already completed today - check_q = select(UserActivityCompletion).where( - UserActivityCompletion.user_id == user.id, - UserActivityCompletion.activity_id == activity.id, - UserActivityCompletion.completed_date == today_wib, - ) - res_check = await db.execute(check_q) - existing = res_check.scalar_one_or_none() - - if not existing: - comp = UserActivityCompletion( - user_id=user.id, - activity_id=activity.id, - completed_date=today_wib, - ) - db.add(comp) - await db.commit() - - return { - "message": "Activity marked as completed for today", - "activityId": str(activity.id), - "completedDate": str(today_wib), - "isCompleted": True, - } - - # 2. Check legacy RecommendationItem - query_legacy = select(RecommendationItem).where(RecommendationItem.id == r_uuid) - res_legacy = await db.execute(query_legacy) - legacy_item = res_legacy.scalar_one_or_none() - - if legacy_item: - legacy_item.is_completed = True - await db.commit() - return { - "message": "Legacy recommendation marked as completed", - "activityId": str(legacy_item.id), - "isCompleted": True, - } - - raise HTTPException(status_code=404, detail="Activity or recommendation item not found") + return await service.mark_completed(user.id, recommendation_id) diff --git a/apps/backend/api/app/api/routes/user.py b/apps/backend/api/app/api/routes/user.py index 6aeb62f..4f06650 100644 --- a/apps/backend/api/app/api/routes/user.py +++ b/apps/backend/api/app/api/routes/user.py @@ -1,204 +1,57 @@ import logging -from typing import Any -import uuid -from fastapi import APIRouter, Depends, Header, HTTPException, status -from pydantic import BaseModel -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends -from app.core.security import decode_access_token -from app.db.session import get_db_session -from app.models.user import EmergencyContact, User +from app.api.dependencies import get_current_user, get_user_service +from app.models.user import User +from app.schemas.user import ( + CreateEmergencyContactRequest, + DeleteContactResponse, + EmergencyContactResponse, + UpdateEmergencyContactRequest, +) +from app.services.user import UserService logger = logging.getLogger(__name__) router = APIRouter(prefix="/users", tags=["Users"]) -class CreateEmergencyContactRequest(BaseModel): - name: str - relationship: str - phone_number: str - is_primary: bool = False - - -class UpdateEmergencyContactRequest(BaseModel): - name: str | None = None - relationship: str | None = None - phone_number: str | None = None - is_primary: bool | None = None - - -async def _get_current_user( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> User: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - -@router.get("/emergency-contacts") +@router.get("/emergency-contacts", response_model=list[EmergencyContactResponse]) async def get_emergency_contacts( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> list[dict[str, Any]]: - query = select(EmergencyContact).where(EmergencyContact.user_id == user.id) - res = await db.execute(query) - contacts = res.scalars().all() + user: User = Depends(get_current_user), + service: UserService = Depends(get_user_service), +) -> list[EmergencyContactResponse]: + """Retrieve all emergency contacts for the authenticated user.""" + return await service.get_emergency_contacts(user.id) - return [ - { - "id": str(c.id), - "name": c.name, - "relationship": c.relationship, - "phone": c.phone_number, - "isPrimary": c.is_primary, - } - for c in contacts - ] - -@router.post("/emergency-contacts") +@router.post("/emergency-contacts", response_model=EmergencyContactResponse) async def create_emergency_contact( payload: CreateEmergencyContactRequest, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - # If new contact is primary, unset others - if payload.is_primary: - existing_q = select(EmergencyContact).where( - EmergencyContact.user_id == user.id, - EmergencyContact.is_primary == True, # noqa: E712 - ) - existing_res = await db.execute(existing_q) - for c in existing_res.scalars().all(): - c.is_primary = False - - contact = EmergencyContact( - user_id=user.id, - name=payload.name, - relationship=payload.relationship, - phone_number=payload.phone_number, - is_primary=payload.is_primary, - ) - db.add(contact) - await db.commit() - await db.refresh(contact) - - return { - "id": str(contact.id), - "name": contact.name, - "relationship": contact.relationship, - "phone": contact.phone_number, - "isPrimary": contact.is_primary, - } + user: User = Depends(get_current_user), + service: UserService = Depends(get_user_service), +) -> EmergencyContactResponse: + """Create a new emergency contact for the authenticated user.""" + return await service.create_emergency_contact(user.id, payload) -@router.put("/emergency-contacts/{contact_id}") +@router.put("/emergency-contacts/{contact_id}", response_model=EmergencyContactResponse) async def update_emergency_contact( contact_id: str, payload: UpdateEmergencyContactRequest, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - try: - c_uuid = uuid.UUID(contact_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid contact ID") - - query = select(EmergencyContact).where( - EmergencyContact.id == c_uuid, - EmergencyContact.user_id == user.id, # ensure ownership - ) - res = await db.execute(query) - contact = res.scalar_one_or_none() - - if not contact: - raise HTTPException(status_code=404, detail="Contact not found") - - if payload.name is not None: - contact.name = payload.name - if payload.relationship is not None: - contact.relationship = payload.relationship - if payload.phone_number is not None: - contact.phone_number = payload.phone_number - if payload.is_primary is not None: - if payload.is_primary: - # Unset other primary contacts - existing_q = select(EmergencyContact).where( - EmergencyContact.user_id == user.id, - EmergencyContact.is_primary == True, # noqa: E712 - ) - existing_res = await db.execute(existing_q) - for c in existing_res.scalars().all(): - c.is_primary = False - contact.is_primary = payload.is_primary + user: User = Depends(get_current_user), + service: UserService = Depends(get_user_service), +) -> EmergencyContactResponse: + """Update existing emergency contact details.""" + return await service.update_emergency_contact(user.id, contact_id, payload) - await db.commit() - await db.refresh(contact) - return { - "id": str(contact.id), - "name": contact.name, - "relationship": contact.relationship, - "phone": contact.phone_number, - "isPrimary": contact.is_primary, - } - - -@router.delete("/emergency-contacts/{contact_id}") +@router.delete("/emergency-contacts/{contact_id}", response_model=DeleteContactResponse) async def delete_emergency_contact( contact_id: str, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, str]: - try: - c_uuid = uuid.UUID(contact_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid contact ID") - - query = select(EmergencyContact).where( - EmergencyContact.id == c_uuid, - EmergencyContact.user_id == user.id, - ) - res = await db.execute(query) - contact = res.scalar_one_or_none() - - if not contact: - raise HTTPException(status_code=404, detail="Contact not found") - - await db.delete(contact) - await db.commit() - return {"message": "Contact deleted successfully"} + user: User = Depends(get_current_user), + service: UserService = Depends(get_user_service), +) -> DeleteContactResponse: + """Delete an emergency contact owned by the user.""" + return await service.delete_emergency_contact(user.id, contact_id) diff --git a/apps/backend/api/app/schemas/analytics/__init__.py b/apps/backend/api/app/schemas/analytics/__init__.py new file mode 100644 index 0000000..f7489df --- /dev/null +++ b/apps/backend/api/app/schemas/analytics/__init__.py @@ -0,0 +1,11 @@ +from app.schemas.analytics.responses import ( + EmotionalCenterResponse, + MonitoringDataResponse, + RiskItemResponse, +) + +__all__ = [ + "EmotionalCenterResponse", + "RiskItemResponse", + "MonitoringDataResponse", +] diff --git a/apps/backend/api/app/schemas/analytics/responses.py b/apps/backend/api/app/schemas/analytics/responses.py new file mode 100644 index 0000000..7233e0d --- /dev/null +++ b/apps/backend/api/app/schemas/analytics/responses.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, ConfigDict + + +class EmotionalCenterResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + status: str + level: int + description: str + textColorHex: str + + +class RiskItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + name: str + type: str + percent: float + levelLabel: str + colorHex: str + badgeBgHex: str + + +class MonitoringDataResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + periodKey: str + periodLabel: str + summary: str + emotionalCenter: EmotionalCenterResponse + risks: list[RiskItemResponse] + xLabels: list[str] + chartData: list[list[float]] diff --git a/apps/backend/api/app/schemas/auth/__init__.py b/apps/backend/api/app/schemas/auth/__init__.py new file mode 100644 index 0000000..64743ce --- /dev/null +++ b/apps/backend/api/app/schemas/auth/__init__.py @@ -0,0 +1,17 @@ +from app.schemas.auth.requests import LoginRequest, RefreshRequest, RegisterRequest +from app.schemas.auth.responses import ( + EmailCheckResponse, + LogoutResponse, + TokenResponse, + UserAuthResponse, +) + +__all__ = [ + "LoginRequest", + "RegisterRequest", + "RefreshRequest", + "UserAuthResponse", + "TokenResponse", + "EmailCheckResponse", + "LogoutResponse", +] diff --git a/apps/backend/api/app/schemas/auth/requests.py b/apps/backend/api/app/schemas/auth/requests.py new file mode 100644 index 0000000..94becf6 --- /dev/null +++ b/apps/backend/api/app/schemas/auth/requests.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, EmailStr + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class RegisterRequest(BaseModel): + name: str + email: EmailStr + password: str + + +class RefreshRequest(BaseModel): + refresh_token: str diff --git a/apps/backend/api/app/schemas/auth/responses.py b/apps/backend/api/app/schemas/auth/responses.py new file mode 100644 index 0000000..bbfc0ae --- /dev/null +++ b/apps/backend/api/app/schemas/auth/responses.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, ConfigDict + + +class UserAuthResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + name: str + email: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + user: UserAuthResponse + + +class EmailCheckResponse(BaseModel): + available: bool + email: str + message: str + + +class LogoutResponse(BaseModel): + message: str = "Logged out successfully" diff --git a/apps/backend/api/app/schemas/conversation/__init__.py b/apps/backend/api/app/schemas/conversation/__init__.py new file mode 100644 index 0000000..1b4542f --- /dev/null +++ b/apps/backend/api/app/schemas/conversation/__init__.py @@ -0,0 +1,31 @@ +from app.schemas.conversation.requests import SendMessageRequest +from app.schemas.conversation.responses import ( + ConversationAnalysis, + ConversationDetailResponse, + ConversationListResponse, + ConversationPagination, + EmotionBreakdownSimple, + MessageItemResponse, + SentMessageResponse, + TodayConversationItem, + TodayConversationResponse, + TodayMessageItem, + TodayMessagesResponse, + TranscriptItem, +) + +__all__ = [ + "SendMessageRequest", + "MessageItemResponse", + "ConversationDetailResponse", + "ConversationPagination", + "ConversationListResponse", + "TranscriptItem", + "EmotionBreakdownSimple", + "ConversationAnalysis", + "TodayConversationItem", + "TodayConversationResponse", + "TodayMessageItem", + "TodayMessagesResponse", + "SentMessageResponse", +] diff --git a/apps/backend/api/app/schemas/conversation/requests.py b/apps/backend/api/app/schemas/conversation/requests.py new file mode 100644 index 0000000..647fdf2 --- /dev/null +++ b/apps/backend/api/app/schemas/conversation/requests.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class SendMessageRequest(BaseModel): + content: str + modality: str = "text" diff --git a/apps/backend/api/app/schemas/conversation/responses.py b/apps/backend/api/app/schemas/conversation/responses.py new file mode 100644 index 0000000..047929a --- /dev/null +++ b/apps/backend/api/app/schemas/conversation/responses.py @@ -0,0 +1,117 @@ +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class MessageItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + sender: str + text: str + time: str + isAudio: bool = False + emotionTag: str | None = None + emotionEmoji: str | None = None + + +class ConversationDetailResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + title: str + is_title_generating: bool = False + lastMessage: str = "" + lastMessageTime: str = "" + duration: str = "00:00" + dominant_emotion: str = "Tenang & Nyaman" + dominant_emoji: str = "😌" + summary: str | None = None + messages: list[MessageItemResponse] = [] + + +class ConversationPagination(BaseModel): + page: int + limit: int + total_items: int + total_pages: int + has_more: bool + + +class ConversationListResponse(BaseModel): + items: list[ConversationDetailResponse] + pagination: ConversationPagination + total: int + page: int + limit: int + has_more: bool + + +class TranscriptItem(BaseModel): + role: str + content: str + time: str + emotionTag: str | None = None + emotionEmoji: str | None = None + + +class EmotionBreakdownSimple(BaseModel): + label: str + emoji: str + percent: float + color: str + + +class ConversationAnalysis(BaseModel): + dominant_emotion: str + calm_score: str + stress_level: str + empathy_level: str + ai_insight: str + emotions_breakdown: list[EmotionBreakdownSimple] = [] + + +class TodayConversationItem(BaseModel): + id: str + title: str + is_title_generating: bool = False + date: str + duration: str + moodTag: str + emotion_analysis: ConversationAnalysis + transcript: list[TranscriptItem] = [] + + +class TodayConversationResponse(BaseModel): + items: list[TodayConversationItem] + total: int + page: int + limit: int + has_more: bool + + +class TodayMessageItem(BaseModel): + id: str + role: str + isUser: bool + content: str + text: str + time: str + sessionTitle: str + emotionTag: str | None = None + emotionEmoji: str | None = None + + +class TodayMessagesResponse(BaseModel): + items: list[TodayMessageItem] + total_items: int + page: int + limit: int + total_pages: int + + +class SentMessageResponse(BaseModel): + id: str + sender: str + text: str + time: str + isAudio: bool = False diff --git a/apps/backend/api/app/schemas/dass/__init__.py b/apps/backend/api/app/schemas/dass/__init__.py new file mode 100644 index 0000000..9969f81 --- /dev/null +++ b/apps/backend/api/app/schemas/dass/__init__.py @@ -0,0 +1,14 @@ +from app.schemas.dass.requests import DASSAssessmentUpdate, DASSItemUpdate +from app.schemas.dass.responses import ( + DASSAssessmentResponse, + DASSItemSchema, + SubscaleScoreSummary, +) + +__all__ = [ + "DASSItemUpdate", + "DASSAssessmentUpdate", + "DASSItemSchema", + "SubscaleScoreSummary", + "DASSAssessmentResponse", +] diff --git a/apps/backend/api/app/schemas/dass/requests.py b/apps/backend/api/app/schemas/dass/requests.py new file mode 100644 index 0000000..ef1e201 --- /dev/null +++ b/apps/backend/api/app/schemas/dass/requests.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field + + +class DASSItemUpdate(BaseModel): + item_id: int = Field(..., ge=1, le=21) + score: int = Field(..., ge=0, le=3) + + +class DASSAssessmentUpdate(BaseModel): + items: list[DASSItemUpdate] = Field(..., min_length=1, max_length=21) diff --git a/apps/backend/api/app/schemas/dass.py b/apps/backend/api/app/schemas/dass/responses.py similarity index 85% rename from apps/backend/api/app/schemas/dass.py rename to apps/backend/api/app/schemas/dass/responses.py index 0ecc624..29d13c0 100644 --- a/apps/backend/api/app/schemas/dass.py +++ b/apps/backend/api/app/schemas/dass/responses.py @@ -15,15 +15,6 @@ class DASSItemSchema(BaseModel): is_user_edited: bool = Field(False, description="Flag apakah skor butir ini telah diedit oleh user") -class DASSItemUpdate(BaseModel): - item_id: int = Field(..., ge=1, le=21) - score: int = Field(..., ge=0, le=3) - - -class DASSAssessmentUpdate(BaseModel): - items: list[DASSItemUpdate] = Field(..., min_length=1, max_length=21) - - class SubscaleScoreSummary(BaseModel): raw_sum: int final_score: int @@ -31,6 +22,8 @@ class SubscaleScoreSummary(BaseModel): class DASSAssessmentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID | None = None user_id: UUID conversation_id: UUID | None = None @@ -44,5 +37,3 @@ class DASSAssessmentResponse(BaseModel): verified_by_user: bool status: str items: list[DASSItemSchema] - - model_config = ConfigDict(from_attributes=True) diff --git a/apps/backend/api/app/schemas/diary/__init__.py b/apps/backend/api/app/schemas/diary/__init__.py new file mode 100644 index 0000000..9b12e80 --- /dev/null +++ b/apps/backend/api/app/schemas/diary/__init__.py @@ -0,0 +1,20 @@ +from app.schemas.diary.requests import CreateDiaryRequest, UpdateDiaryRequest +from app.schemas.diary.responses import ( + DeleteDiaryResponse, + DiaryEntryResponse, + DiaryRiskWarning, + DiarySessionItem, + EmotionBreakdownItem, + TranscriptItem, +) + +__all__ = [ + "CreateDiaryRequest", + "UpdateDiaryRequest", + "EmotionBreakdownItem", + "TranscriptItem", + "DiarySessionItem", + "DiaryRiskWarning", + "DiaryEntryResponse", + "DeleteDiaryResponse", +] diff --git a/apps/backend/api/app/schemas/diary/requests.py b/apps/backend/api/app/schemas/diary/requests.py new file mode 100644 index 0000000..ad4f308 --- /dev/null +++ b/apps/backend/api/app/schemas/diary/requests.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class CreateDiaryRequest(BaseModel): + content: str + mood_tag: str | None = None + + +class UpdateDiaryRequest(BaseModel): + content: str | None = None + mood_tag: str | None = None + title: str | None = None diff --git a/apps/backend/api/app/schemas/diary/responses.py b/apps/backend/api/app/schemas/diary/responses.py new file mode 100644 index 0000000..7913496 --- /dev/null +++ b/apps/backend/api/app/schemas/diary/responses.py @@ -0,0 +1,61 @@ +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class EmotionBreakdownItem(BaseModel): + name: str + label: str + emoji: str + percent: float + color: str + + +class TranscriptItem(BaseModel): + isUser: bool + time: str + text: str + emotionTag: str | None = None + emotionEmoji: str | None = None + + +class DiarySessionItem(BaseModel): + id: str + title: str + time: str + moodTag: str + moodEmoji: str + emotionsBreakdown: list[EmotionBreakdownItem] = [] + transcripts: list[TranscriptItem] = [] + + +class DiaryRiskWarning(BaseModel): + detected: bool + type: str + title: str + level: str + message: str + + +class DiaryEntryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str | None = None + title: str | None = None + date: str | None = None + sessionCount: int = 0 + lastSessionTime: str = "-" + moodTag: str = "Netral" + moodEmoji: str = "😌" + summary: str = "" + riskWarning: DiaryRiskWarning | None = None + aiInsight: str = "" + importantEvents: list[str] = [] + emotionalReflection: str = "" + mentalHealthScores: dict[str, Any] = {} + emotionsBreakdown: list[EmotionBreakdownItem] = [] + sessions: list[DiarySessionItem] = [] + message: str | None = None + + +class DeleteDiaryResponse(BaseModel): + message: str = "Diary entry deleted successfully" diff --git a/apps/backend/api/app/schemas/health/__init__.py b/apps/backend/api/app/schemas/health/__init__.py new file mode 100644 index 0000000..d6740ce --- /dev/null +++ b/apps/backend/api/app/schemas/health/__init__.py @@ -0,0 +1,3 @@ +from app.schemas.health.responses import HealthCheckResponse + +__all__ = ["HealthCheckResponse"] diff --git a/apps/backend/api/app/schemas/health.py b/apps/backend/api/app/schemas/health/responses.py similarity index 59% rename from apps/backend/api/app/schemas/health.py rename to apps/backend/api/app/schemas/health/responses.py index cb4c15b..cc51250 100644 --- a/apps/backend/api/app/schemas/health.py +++ b/apps/backend/api/app/schemas/health/responses.py @@ -1,7 +1,9 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class HealthCheckResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + status: str app: str environment: str diff --git a/apps/backend/api/app/schemas/recommendation/__init__.py b/apps/backend/api/app/schemas/recommendation/__init__.py new file mode 100644 index 0000000..81ca16a --- /dev/null +++ b/apps/backend/api/app/schemas/recommendation/__init__.py @@ -0,0 +1,11 @@ +from app.schemas.recommendation.responses import ( + CompleteActivityResponse, + CopingActivityResponse, + TodayRecommendationResponse, +) + +__all__ = [ + "CopingActivityResponse", + "TodayRecommendationResponse", + "CompleteActivityResponse", +] diff --git a/apps/backend/api/app/schemas/recommendation/responses.py b/apps/backend/api/app/schemas/recommendation/responses.py new file mode 100644 index 0000000..d7d3551 --- /dev/null +++ b/apps/backend/api/app/schemas/recommendation/responses.py @@ -0,0 +1,40 @@ +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class CopingActivityResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + title: str + category: str + targetCondition: str + duration: str + difficulty: str + level: str + description: str + instructions: list[str] = [] + rationale: str = "" + iconName: str = "spa" + isCompleted: bool = False + + +class TodayRecommendationResponse(BaseModel): + targetCondition: str + conditionLabel: str + conditionReason: str + severityLevel: str + isCrisis: bool + crisisHotline: str + crisisGuidance: str + todayActivity: CopingActivityResponse + completedTodayCount: int + alternatives: list[CopingActivityResponse] = [] + allRecommendations: list[CopingActivityResponse] = [] + + +class CompleteActivityResponse(BaseModel): + message: str + activityId: str + completedDate: str | None = None + isCompleted: bool = True diff --git a/apps/backend/api/app/schemas/user/__init__.py b/apps/backend/api/app/schemas/user/__init__.py new file mode 100644 index 0000000..47e3f26 --- /dev/null +++ b/apps/backend/api/app/schemas/user/__init__.py @@ -0,0 +1,15 @@ +from app.schemas.user.requests import ( + CreateEmergencyContactRequest, + UpdateEmergencyContactRequest, +) +from app.schemas.user.responses import ( + DeleteContactResponse, + EmergencyContactResponse, +) + +__all__ = [ + "CreateEmergencyContactRequest", + "UpdateEmergencyContactRequest", + "EmergencyContactResponse", + "DeleteContactResponse", +] diff --git a/apps/backend/api/app/schemas/user/requests.py b/apps/backend/api/app/schemas/user/requests.py new file mode 100644 index 0000000..046b353 --- /dev/null +++ b/apps/backend/api/app/schemas/user/requests.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class CreateEmergencyContactRequest(BaseModel): + name: str + relationship: str + phone_number: str + is_primary: bool = False + + +class UpdateEmergencyContactRequest(BaseModel): + name: str | None = None + relationship: str | None = None + phone_number: str | None = None + is_primary: bool | None = None diff --git a/apps/backend/api/app/schemas/user/responses.py b/apps/backend/api/app/schemas/user/responses.py new file mode 100644 index 0000000..bb45052 --- /dev/null +++ b/apps/backend/api/app/schemas/user/responses.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, ConfigDict + + +class EmergencyContactResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + name: str + relationship: str + phone: str + isPrimary: bool + + +class DeleteContactResponse(BaseModel): + message: str = "Contact deleted successfully" diff --git a/apps/backend/api/app/services/analytics/__init__.py b/apps/backend/api/app/services/analytics/__init__.py new file mode 100644 index 0000000..12ac2d0 --- /dev/null +++ b/apps/backend/api/app/services/analytics/__init__.py @@ -0,0 +1,3 @@ +from app.services.analytics.analytics_service import AnalyticsService + +__all__ = ["AnalyticsService"] diff --git a/apps/backend/api/app/services/analytics_service.py b/apps/backend/api/app/services/analytics/analytics_service.py similarity index 98% rename from apps/backend/api/app/services/analytics_service.py rename to apps/backend/api/app/services/analytics/analytics_service.py index 33bcab2..436d3c3 100644 --- a/apps/backend/api/app/services/analytics_service.py +++ b/apps/backend/api/app/services/analytics/analytics_service.py @@ -13,6 +13,7 @@ from app.models.dass import DASSAssessment from app.models.diary import DiaryEntry from app.models.safety import EmotionAnalysis +from app.schemas.analytics import MonitoringDataResponse logger = logging.getLogger(__name__) @@ -22,6 +23,15 @@ class AnalyticsService: for the Monitoring/Tren screen across 'today', 'week', and 'month' periods. """ + def __init__(self, db: AsyncSession): + self.db = db + + async def get_data(self, user_id: uuid.UUID, period: str = "today") -> MonitoringDataResponse: + """Fetch monitoring data for the user and return structured Pydantic response.""" + raw = await self.get_monitoring_data(user_id, period, self.db) + return MonitoringDataResponse.model_validate(raw) + + # 7 UI Emotions index mapping: # 0: happy, 1: netral, 2: fear, 3: sadness, 4: surprise, 5: anger, 6: disgusted EMOTION_MAP = { diff --git a/apps/backend/api/app/services/auth/__init__.py b/apps/backend/api/app/services/auth/__init__.py new file mode 100644 index 0000000..db94126 --- /dev/null +++ b/apps/backend/api/app/services/auth/__init__.py @@ -0,0 +1,3 @@ +from app.services.auth.auth_service import AuthService + +__all__ = ["AuthService"] diff --git a/apps/backend/api/app/services/auth/auth_service.py b/apps/backend/api/app/services/auth/auth_service.py new file mode 100644 index 0000000..cab51ec --- /dev/null +++ b/apps/backend/api/app/services/auth/auth_service.py @@ -0,0 +1,217 @@ +import logging +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import ( + create_access_token, + create_refresh_token, + decode_access_token, + decode_refresh_token, + hash_password, + verify_password, +) +from app.models.user import User +from app.schemas.auth import ( + EmailCheckResponse, + LoginRequest, + LogoutResponse, + RefreshRequest, + RegisterRequest, + TokenResponse, + UserAuthResponse, +) + +logger = logging.getLogger(__name__) + + +class AuthService: + """Service handling user authentication, registration, and token lifecycles.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def check_email(self, email: str) -> EmailCheckResponse: + """Check if an email is valid and available for registration.""" + clean_email = email.strip().lower() + if not clean_email or "@" not in clean_email or "." not in clean_email: + return EmailCheckResponse( + available=False, + email=clean_email, + message="Format email tidak valid", + ) + + query = select(User).where(User.email == clean_email) + res = await self.db.execute(query) + existing = res.scalar_one_or_none() + + if existing: + return EmailCheckResponse( + available=False, + email=clean_email, + message="Email sudah terdaftar", + ) + + return EmailCheckResponse( + available=True, + email=clean_email, + message="Email tersedia", + ) + + async def login(self, payload: LoginRequest) -> TokenResponse: + """Authenticate user by email and password, returning JWT access & refresh tokens.""" + clean_email = payload.email.strip().lower() + query = select(User).where(User.email == clean_email) + res = await self.db.execute(query) + user = res.scalar_one_or_none() + + if not user or not verify_password(payload.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Email atau kata sandi tidak valid", + ) + + display_name = user.display_name or user.username or "User Eva" + access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) + refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + user=UserAuthResponse( + id=str(user.id), + name=display_name, + email=user.email, + ), + ) + + async def register(self, payload: RegisterRequest) -> TokenResponse: + """Register a new user, commit to database, and return initial JWT tokens.""" + clean_email = payload.email.strip().lower() + query = select(User).where(User.email == clean_email) + res = await self.db.execute(query) + existing = res.scalar_one_or_none() + + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email sudah terdaftar", + ) + + clean_name = payload.name.strip() + user = User( + email=clean_email, + username=clean_name, + display_name=clean_name, + password_hash=hash_password(payload.password), + is_active=True, + is_verified=True, + ) + self.db.add(user) + await self.db.commit() + await self.db.refresh(user) + + display_name = user.display_name or clean_name + access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) + refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) + + logger.info(f"✅ [AUTH] User registered successfully: {clean_email} ({user.id})") + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + user=UserAuthResponse( + id=str(user.id), + name=display_name, + email=user.email, + ), + ) + + async def refresh_token(self, payload: RefreshRequest) -> TokenResponse: + """Issue a new access and refresh token pair using a valid refresh token.""" + decoded = decode_refresh_token(payload.refresh_token) + if not decoded or "sub" not in decoded: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token tidak valid atau telah kedaluwarsa", + ) + + try: + u_uuid = uuid.UUID(decoded["sub"]) + except ValueError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Format user ID token tidak valid", + ) + + query = select(User).where(User.id == u_uuid) + res = await self.db.execute(query) + user = res.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User tidak ditemukan atau tidak aktif", + ) + + display_name = user.display_name or user.username or "User Eva" + new_access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) + new_refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) + + return TokenResponse( + access_token=new_access_token, + refresh_token=new_refresh_token, + token_type="bearer", + user=UserAuthResponse( + id=str(user.id), + name=display_name, + email=user.email, + ), + ) + + async def get_user_from_token(self, token: str) -> User: + """Decode access token, parse user UUID, and retrieve User model from database.""" + decoded = decode_access_token(token) + if not decoded or "sub" not in decoded: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token akses tidak valid atau telah kedaluwarsa", + ) + + try: + user_uuid = uuid.UUID(decoded["sub"]) + except (ValueError, TypeError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Format user ID dalam token tidak valid", + ) + + query = select(User).where(User.id == user_uuid) + res = await self.db.execute(query) + user = res.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Pengguna tidak ditemukan atau telah dinonaktifkan", + ) + + return user + + @staticmethod + def get_user_profile(user: User) -> UserAuthResponse: + """Format an authenticated User instance into a UserAuthResponse DTO.""" + return UserAuthResponse( + id=str(user.id), + name=user.display_name or user.username or "User Eva", + email=user.email, + ) + + @staticmethod + def logout() -> LogoutResponse: + """Return successful logout acknowledgment.""" + return LogoutResponse() diff --git a/apps/backend/api/app/services/call/__init__.py b/apps/backend/api/app/services/call/__init__.py new file mode 100644 index 0000000..964ea1d --- /dev/null +++ b/apps/backend/api/app/services/call/__init__.py @@ -0,0 +1,33 @@ +from app.services.call.persistence import CallPersistenceService +from app.services.call.session_manager import CallSession, CallSessionManager, call_session_manager +from app.services.call.stream_buffer import ( + AudioStreamBufferService, + SessionAudioBuffer, + analyze_audio_placeholder, + audio_buffer_service, +) +from app.services.call.turn_aggregator import ( + TurnAggregator, + merge_overlapping_segments, +) +from app.services.call.turn_detector import ( + TurnDecisionType, + TurnDetectionResult, + TurnDetectionService, +) + +__all__ = [ + "CallSession", + "CallSessionManager", + "call_session_manager", + "CallPersistenceService", + "AudioStreamBufferService", + "SessionAudioBuffer", + "audio_buffer_service", + "analyze_audio_placeholder", + "TurnAggregator", + "merge_overlapping_segments", + "TurnDetectionResult", + "TurnDetectionService", + "TurnDecisionType", +] diff --git a/apps/backend/api/app/services/call_persistence_service.py b/apps/backend/api/app/services/call/persistence.py similarity index 97% rename from apps/backend/api/app/services/call_persistence_service.py rename to apps/backend/api/app/services/call/persistence.py index 70892b6..51330ce 100644 --- a/apps/backend/api/app/services/call_persistence_service.py +++ b/apps/backend/api/app/services/call/persistence.py @@ -12,11 +12,10 @@ from app.models.conversation import Conversation, ConversationSummary, Message, MessageType from app.models.dass import DASSAssessment from app.models.user import User -from app.services.audio_stream_buffer import audio_buffer_service -from app.services.dass_service import DASSService -from app.services.diary_generator import DiaryGeneratorService -from app.services.emotion_analyzer import EmotionAnalyzerService -from app.services.ml_emotion_detector import MLEmotionDetectorService +from app.services.call.stream_buffer import audio_buffer_service +from app.services.dass import DASSService +from app.services.diary import DiaryGeneratorService +from app.services.emotion import EmotionAnalyzerService, MLEmotionDetectorService from packages.ai.services.dass_extraction_service import DASSExtractionService logger = logging.getLogger(__name__) diff --git a/apps/backend/api/app/services/call_session_manager.py b/apps/backend/api/app/services/call/session_manager.py similarity index 93% rename from apps/backend/api/app/services/call_session_manager.py rename to apps/backend/api/app/services/call/session_manager.py index b5d09b0..9a98bb7 100644 --- a/apps/backend/api/app/services/call_session_manager.py +++ b/apps/backend/api/app/services/call/session_manager.py @@ -8,7 +8,7 @@ from typing import Any import uuid -from fastapi import WebSocket +from fastapi import WebSocket, WebSocketDisconnect from sqlalchemy import desc, select from sqlalchemy.orm import selectinload @@ -23,13 +23,12 @@ from app.models.diary import DiaryEntry from app.models.enums import MessageType from app.models.user import User -from app.services.audio_stream_buffer import audio_buffer_service -from app.services.call_persistence_service import CallPersistenceService -from app.services.dass_service import DASSService -from app.services.diary_generator import DiaryGeneratorService -from app.services.emotion_analyzer import EmotionAnalyzerService -from app.services.ml_emotion_detector import MLEmotionDetectorService -from app.services.turn_aggregator import TurnAggregator +from app.services.call.persistence import CallPersistenceService +from app.services.call.stream_buffer import audio_buffer_service +from app.services.call.turn_aggregator import TurnAggregator +from app.services.dass import DASSService +from app.services.diary import DiaryGeneratorService +from app.services.emotion import EmotionAnalyzerService, MLEmotionDetectorService from packages.ai.factories.llm_factory import LLMFactory from packages.ai.factories.tts_factory import TTSFactory from packages.ai.interfaces.llm import LLMMessage @@ -148,6 +147,60 @@ def unregister_session(self, session_id: str) -> None: def get_session(self, session_id: str) -> CallSession | None: return self._active_sessions.get(session_id) + async def handle_connection( + self, + websocket: WebSocket, + session_id: str, + token: str | None = None, + voice_mode: str | None = None, + ) -> None: + """Handle entire bi-directional WebSocket connection lifecycle for AI Phone Call sessions.""" + await websocket.accept() + session = self.register_session(session_id, websocket, auth_token=token, voice_mode=voice_mode) + logger.info(f"🔌 [WEBSOCKET CONNECTED] Session: {session_id} (Voice Mode: {session.voice_mode})") + + try: + # Pre-load previous conversation history from DB so DeepSeek LLM remembers past context + await self.load_conversation_history(session) + + # Notify client connection accepted + await websocket.send_json({ + "type": "call_connected", + "session_id": session_id, + "message": "Connected to Eva AI Phone Service", + }) + + while True: + message = await websocket.receive() + if "bytes" in message and message["bytes"]: + audio_bytes = message["bytes"] + logger.info(f"🎙️ [INCOMING MIC AUDIO] Session {session_id}: Received {len(audio_bytes)} raw mic bytes") + await audio_buffer_service.handle_incoming_audio_chunk(session_id, audio_bytes) + + elif "text" in message and message["text"]: + try: + payload = json.loads(message["text"]) + event_type = payload.get("type") + + if event_type == "end_call": + duration_sec = payload.get("duration_seconds") + logger.info(f"🛑 [CALL ENDED BY USER] Session {session_id}, Duration: {duration_sec}s") + await self.update_session_end(session_id, duration_seconds=duration_sec) + break + + await self.handle_event(session, payload) + + except json.JSONDecodeError: + logger.warning(f"⚠️ [INVALID JSON] Session {session_id}: Received malformed JSON") + + except WebSocketDisconnect: + logger.info(f"🔌 [WEBSOCKET DISCONNECTED] Session {session_id}") + except Exception as e: + logger.error(f"❌ [WEBSOCKET ERROR] Session {session_id}: {e}") + finally: + self.unregister_session(session_id) + audio_buffer_service.clear_session(session_id) + async def load_conversation_history(self, session: CallSession) -> None: await CallPersistenceService.load_conversation_history(session) diff --git a/apps/backend/api/app/services/audio_stream_buffer.py b/apps/backend/api/app/services/call/stream_buffer.py similarity index 100% rename from apps/backend/api/app/services/audio_stream_buffer.py rename to apps/backend/api/app/services/call/stream_buffer.py diff --git a/apps/backend/api/app/services/turn_aggregator.py b/apps/backend/api/app/services/call/turn_aggregator.py similarity index 98% rename from apps/backend/api/app/services/turn_aggregator.py rename to apps/backend/api/app/services/call/turn_aggregator.py index ed9d24e..1d89c9b 100644 --- a/apps/backend/api/app/services/turn_aggregator.py +++ b/apps/backend/api/app/services/call/turn_aggregator.py @@ -4,7 +4,7 @@ from typing import Any from app.core.call_config import CallTurnConfig, call_config -from app.services.turn_detection_service import TurnDetectionResult, TurnDetectionService +from app.services.call.turn_detector import TurnDetectionResult, TurnDetectionService logger = logging.getLogger(__name__) diff --git a/apps/backend/api/app/services/turn_detection_service.py b/apps/backend/api/app/services/call/turn_detector.py similarity index 100% rename from apps/backend/api/app/services/turn_detection_service.py rename to apps/backend/api/app/services/call/turn_detector.py diff --git a/apps/backend/api/app/services/conversation/__init__.py b/apps/backend/api/app/services/conversation/__init__.py new file mode 100644 index 0000000..f31bdc8 --- /dev/null +++ b/apps/backend/api/app/services/conversation/__init__.py @@ -0,0 +1,19 @@ +from app.services.conversation.conversation_service import ( + ConversationService, + analyze_message_emotion_fallback, + format_conversation, + format_duration_str, + format_time_local, + get_message_emotion, +) +from app.services.conversation.summary_stream_service import SummaryStreamService + +__all__ = [ + "ConversationService", + "SummaryStreamService", + "format_time_local", + "format_duration_str", + "analyze_message_emotion_fallback", + "get_message_emotion", + "format_conversation", +] diff --git a/apps/backend/api/app/services/conversation/conversation_service.py b/apps/backend/api/app/services/conversation/conversation_service.py new file mode 100644 index 0000000..c89fe9b --- /dev/null +++ b/apps/backend/api/app/services/conversation/conversation_service.py @@ -0,0 +1,536 @@ +from collections import Counter +from datetime import datetime, timezone +import logging +import math +from typing import Any +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.tz import get_wib_day_range_utc, get_wib_today, to_wib +from app.models.conversation import Conversation, ConversationSummary, Message +from app.models.diary import DiaryEntry +from app.models.enums import MessageType +from app.models.safety import EmotionAnalysis +from app.schemas.conversation import ( + ConversationAnalysis, + ConversationDetailResponse, + ConversationListResponse, + ConversationPagination, + EmotionBreakdownSimple, + MessageItemResponse, + SendMessageRequest, + SentMessageResponse, + TodayConversationItem, + TodayConversationResponse, + TodayMessageItem, + TodayMessagesResponse, + TranscriptItem, +) +from app.services.emotion import MLEmotionDetectorService + +logger = logging.getLogger(__name__) + + +def format_time_local(dt: datetime | None) -> str: + """Format datetime into 12-hour local time format (hh:mm AM/PM).""" + if not dt: + return "09:15 AM" + try: + dt_wib = to_wib(dt) + return dt_wib.strftime("%I:%M %p") if dt_wib else "09:15 AM" + except Exception: + return dt.strftime("%I:%M %p") + + +def format_duration_str(started_at: datetime | None, last_message_at: datetime | None) -> str: + """Calculate elapsed session duration and format as MM:SS.""" + if not started_at or not last_message_at: + return "00:00" + try: + t1 = started_at if started_at.tzinfo else started_at.replace(tzinfo=timezone.utc) + t2 = last_message_at if last_message_at.tzinfo else last_message_at.replace(tzinfo=timezone.utc) + diff = int((t2 - t1).total_seconds()) + if diff < 0: + diff = 0 + mins = diff // 60 + secs = diff % 60 + return f"{mins:02d}:{secs:02d}" + except Exception: + return "00:00" + + +def analyze_message_emotion_fallback(text: str) -> tuple[str, str]: + """Determine emotion tag and emoji dynamically from message text keywords.""" + t_low = (text or "").lower() + anx_words = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup", "takutnya", "bingung", "was-was", "tegang"] + dep_words = ["sedih", "nangis", "menangis", "hampa", "sendiri", "kehilangan", "terpuruk", "putus asa", "kecewa", "patah hati", "bunuh diri"] + str_words = ["stres", "capek", "lelah", "beban", "berat", "penat", "pusing", "mumet", "tekanan", "tugas", "deadline", "kerjaan", "letih"] + ang_words = ["marah", "kesal", "jengkel", "benci", "emosi", "sebal", "kesel", "geram", "murka"] + pos_words = ["lega", "senang", "bahagia", "terima kasih", "makasih", "enakan", "tenang", "nyaman", "santai", "alhamdulillah", "syukurlah", "baik", "bersyukur"] + + if any(w in t_low for w in anx_words): + return "fear (78%)", "😨" + if any(w in t_low for w in dep_words): + return "sadness (82%)", "😔" + if any(w in t_low for w in str_words): + return "stress (75%)", "💥" + if any(w in t_low for w in ang_words): + return "anger (70%)", "😡" + if any(w in t_low for w in pos_words): + return "calm (85%)", "😌" + + return "netral (60%)", "😐" + + +def get_message_emotion( + m: Message, emotion_map: dict[uuid.UUID, EmotionAnalysis] | None = None +) -> tuple[str, str]: + """Retrieve emotion tag and emoji for a message from ML emotion map or keyword heuristic.""" + if m.role not in ("user",): + return "", "" + + if emotion_map and m.id in emotion_map: + emo = emotion_map[m.id] + if emo and emo.primary_emotion: + pct = int((float(emo.confidence) if emo.confidence else 0.8) * 100) + emo_lower = emo.primary_emotion.lower() + name_map = { + "fearful": "fear", + "sad": "sadness", + "angry": "anger", + "neutral": "netral", + "happy": "joy", + } + display_name = name_map.get(emo_lower, emo_lower) + emoji_map = { + "happy": "😃", "joy": "😃", "senang": "😃", "bahagia": "😃", + "calm": "😌", "tenang": "😌", "lega": "😌", + "fearful": "😨", "fear": "😨", "cemas": "😨", "takut": "😨", + "sad": "😔", "sadness": "😔", "sedih": "😔", + "angry": "😡", "anger": "😡", "marah": "😡", + "stress": "💥", "stres": "💥", + "neutral": "😐", "netral": "😐", + } + return f"{display_name} ({pct}%)", emoji_map.get(emo_lower, "🌱") + + return analyze_message_emotion_fallback(m.content) + + +def format_conversation( + c: Conversation, emotion_map: dict[uuid.UUID, EmotionAnalysis] | None = None +) -> ConversationDetailResponse: + """Format a conversation entity into a structured ConversationDetailResponse DTO.""" + msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] + last_msg = msgs[-1].content if msgs else "" + formatted_msgs = [] + user_emotions: list[tuple[str, str]] = [] + for m in msgs: + emo_tag, emo_emoji = get_message_emotion(m, emotion_map) + if m.role in ("user",) and emo_tag: + user_emotions.append((emo_tag, emo_emoji)) + formatted_msgs.append( + MessageItemResponse( + id=str(m.id), + sender="eva" if m.role in ("assistant", "luna", "eva") else "user", + text=m.content, + time=format_time_local(m.created_at), + isAudio=m.message_type in (MessageType.VOICE, "voice", "audio"), + emotionTag=emo_tag or None, + emotionEmoji=emo_emoji or None, + ) + ) + + dominant_label = "Tenang & Nyaman" + dominant_emoji = "😌" + if user_emotions: + counts: Counter[str] = Counter() + emoji_by_name: dict[str, str] = {} + for tag, emoji in user_emotions: + name = tag.split()[0].lower() if tag else "netral" + counts[name] += 1 + if emoji: + emoji_by_name[name] = emoji + + most_common_name, _ = counts.most_common(1)[0] + name_map = { + "happy": "Lega & Senang", + "joy": "Lega & Senang", + "calm": "Tenang & Damai", + "fear": "Cemas & Takut", + "fearful": "Cemas & Takut", + "anxiety": "Cemas & Gelisah", + "sadness": "Sedih & Hampa", + "sad": "Sedih & Hampa", + "depression": "Sedih & Tertekan", + "stress": "Stres & Lelah", + "anger": "Marah & Kesal", + "angry": "Marah & Kesal", + "neutral": "Netral & Rileks", + "netral": "Netral & Rileks", + } + dominant_label = name_map.get(most_common_name, most_common_name.capitalize()) + dominant_emoji = emoji_by_name.get(most_common_name, "😌") + + crisis_keywords = ( + "bunuh diri", "capek hidup", "mati saja", "ingin mati", "putus asa", + "menyakiti diri", "gantung diri", "akhiri hidup", "tak sanggup lagi", + "tidak sanggup hidup" + ) + all_user_text = " ".join([m.content.lower() for m in msgs if m.role == "user" and m.content]) + if any(kw in all_user_text for kw in crisis_keywords): + dominant_label = "Krisis / Sangat Tertekan" + dominant_emoji = "🆘" + + end_time = c.last_message_at or (msgs[-1].created_at if msgs else c.started_at) + duration_str = format_duration_str(c.started_at, end_time) + + summary_text = None + if hasattr(c, "summaries") and c.summaries: + summary_text = c.summaries[-1].summary + + return ConversationDetailResponse( + id=str(c.id), + title=c.title or "Sesi Percakapan", + is_title_generating="$skeleton" in (c.title or ""), + lastMessage=last_msg, + lastMessageTime=format_time_local(c.started_at), + duration=duration_str, + dominant_emotion=dominant_label, + dominant_emoji=dominant_emoji, + summary=summary_text, + messages=formatted_msgs, + ) + + +class ConversationService: + """Service to handle conversation sessions, message pagination, emotion aggregation, and chat persistence.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_conversations( + self, user_id: uuid.UUID, page: int = 1, limit: int = 10 + ) -> ConversationListResponse: + """Retrieve paginated conversations for a user with messages and emotion mappings.""" + count_query = ( + select(func.count()) + .select_from(Conversation) + .where(Conversation.user_id == user_id) + ) + total_res = await self.db.execute(count_query) + total_items = total_res.scalar() or 0 + + offset = (page - 1) * limit + query = ( + select(Conversation) + .options( + selectinload(Conversation.messages), + selectinload(Conversation.summaries), + ) + .where(Conversation.user_id == user_id) + .order_by(Conversation.started_at.desc()) + .offset(offset) + .limit(limit) + ) + res = await self.db.execute(query) + convs = res.scalars().all() + + message_ids = [m.id for c in convs for m in c.messages] + emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} + if message_ids: + emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) + emo_res = await self.db.execute(emo_query) + for emo in emo_res.scalars().all(): + emotion_map[emo.message_id] = emo + + formatted_items = [format_conversation(c, emotion_map) for c in convs] + total_pages = max(1, math.ceil(total_items / limit)) if total_items > 0 else 1 + has_more = (offset + len(convs)) < total_items + + return ConversationListResponse( + items=formatted_items, + pagination=ConversationPagination( + page=page, + limit=limit, + total_items=total_items, + total_pages=total_pages, + has_more=has_more, + ), + total=total_items, + page=page, + limit=limit, + has_more=has_more, + ) + + async def get_today_conversations( + self, user_id: uuid.UUID, page: int = 1, limit: int = 10 + ) -> TodayConversationResponse: + """Retrieve today's voice call sessions with emotion breakdown and pagination.""" + today_wib = get_wib_today() + start_utc, end_utc = get_wib_day_range_utc(today_wib) + + count_query = ( + select(func.count()) + .select_from(Conversation) + .where( + Conversation.user_id == user_id, + Conversation.started_at >= start_utc, + Conversation.started_at <= end_utc, + ) + ) + total_res = await self.db.execute(count_query) + total_items = total_res.scalar() or 0 + + offset = (page - 1) * limit + query = ( + select(Conversation) + .options(selectinload(Conversation.messages)) + .where( + Conversation.user_id == user_id, + Conversation.started_at >= start_utc, + Conversation.started_at <= end_utc, + ) + .order_by(Conversation.started_at.desc()) + .offset(offset) + .limit(limit) + ) + res = await self.db.execute(query) + convs = res.scalars().all() + + message_ids = [m.id for c in convs for m in c.messages] + emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} + if message_ids: + emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) + emo_res = await self.db.execute(emo_query) + for emo in emo_res.scalars().all(): + emotion_map[emo.message_id] = emo + + diary_query = select(DiaryEntry).where(DiaryEntry.user_id == user_id, DiaryEntry.entry_date == today_wib) + res_diary = await self.db.execute(diary_query) + today_diary = res_diary.scalar_one_or_none() + + mood_tag = today_diary.mood_tag if today_diary and today_diary.mood_tag else "Tenang & Nyaman 🌿" + ai_insight = today_diary.ai_insight if today_diary and today_diary.ai_insight else "Pengguna merasa didengarkan dan tenang setelah berdialog bersama LUNA." + + items: list[TodayConversationItem] = [] + for c in convs: + msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] + if not msgs: + continue + + time_formatted = format_time_local(c.started_at) + c_emotions = [emotion_map[m.id].emotions for m in msgs if m.id in emotion_map and emotion_map[m.id].emotions] + if c_emotions: + raw_breakdown = MLEmotionDetectorService.aggregate_session_emotions(c_emotions) + breakdown = [ + EmotionBreakdownSimple( + label=b["label"], + emoji=b["emoji"], + percent=float(b["percent"]), + color=b["color"], + ) + for b in raw_breakdown + ] + else: + breakdown = [ + EmotionBreakdownSimple(label="Ketenangan & Kedamaian", emoji="😌", percent=0.70, color="#4ECDC4"), + EmotionBreakdownSimple(label="Bahagia & Puas", emoji="😃", percent=0.20, color="#FFE6A7"), + EmotionBreakdownSimple(label="Tingkat Stres", emoji="😟", percent=0.10, color="#FF8B94"), + ] + + analysis = ConversationAnalysis( + dominant_emotion=mood_tag, + calm_score="85%", + stress_level="Rendah", + empathy_level="Sangat Tinggi", + ai_insight=ai_insight, + emotions_breakdown=breakdown, + ) + + transcript_items: list[TranscriptItem] = [] + for m in msgs: + emo_tag, emo_emoji = get_message_emotion(m, emotion_map) + transcript_items.append( + TranscriptItem( + role=m.role, + content=m.content, + time=format_time_local(m.created_at), + emotionTag=emo_tag if m.role == "user" else None, + emotionEmoji=emo_emoji if m.role == "user" else None, + ) + ) + + items.append( + TodayConversationItem( + id=str(c.id), + title=c.title or "Sesi Panggilan Suara LUNA", + is_title_generating="$skeleton" in (c.title or ""), + date=f"Hari ini, {time_formatted}", + duration=format_duration_str(c.started_at, c.last_message_at), + moodTag=mood_tag, + emotion_analysis=analysis, + transcript=transcript_items, + ) + ) + + return TodayConversationResponse( + items=items, + total=total_items, + page=page, + limit=limit, + has_more=(offset + len(items)) < total_items, + ) + + async def get_today_messages( + self, user_id: uuid.UUID, page: int = 1, limit: int = 10 + ) -> TodayMessagesResponse: + """Retrieve all messages from today's conversations ordered chronologically.""" + today_wib = get_wib_today() + start_utc, end_utc = get_wib_day_range_utc(today_wib) + + conv_query = ( + select(Conversation) + .options(selectinload(Conversation.messages)) + .where( + Conversation.user_id == user_id, + Conversation.started_at >= start_utc, + Conversation.started_at <= end_utc, + ) + .order_by(Conversation.started_at.asc()) + ) + conv_res = await self.db.execute(conv_query) + convs = conv_res.scalars().all() + + message_ids = [m.id for c in convs for m in c.messages] + emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} + if message_ids: + emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) + emo_res = await self.db.execute(emo_query) + for emo in emo_res.scalars().all(): + emotion_map[emo.message_id] = emo + + all_msgs: list[TodayMessageItem] = [] + for c in convs: + sorted_msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] + sess_title = (c.title or "Sesi Panggilan Suara LUNA").replace("$skeleton", "").strip() + for m in sorted_msgs: + if m.role in ("user", "assistant") and m.content and m.content.strip(): + is_user = m.role == "user" + emo_tag, emo_emoji = get_message_emotion(m, emotion_map) + all_msgs.append( + TodayMessageItem( + id=str(m.id), + role=m.role, + isUser=is_user, + content=m.content, + text=m.content, + time=format_time_local(m.created_at or c.started_at), + sessionTitle=sess_title, + emotionTag=emo_tag if is_user else None, + emotionEmoji=emo_emoji if is_user else None, + ) + ) + + total_items = len(all_msgs) + total_pages = (total_items + limit - 1) // limit if total_items > 0 else 1 + offset = (page - 1) * limit + items = all_msgs[offset : offset + limit] + + return TodayMessagesResponse( + items=items, + total_items=total_items, + page=page, + limit=limit, + total_pages=total_pages, + ) + + async def get_conversation_by_id(self, conversation_id: str) -> ConversationDetailResponse: + """Retrieve single conversation detail and transcript.""" + try: + c_uuid = uuid.UUID(conversation_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid conversation ID") + + query = ( + select(Conversation) + .options( + selectinload(Conversation.messages), + selectinload(Conversation.summaries), + ) + .where(Conversation.id == c_uuid) + ) + res = await self.db.execute(query) + conv = res.scalar_one_or_none() + + if not conv: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") + + message_ids = [m.id for m in conv.messages] if conv.messages else [] + emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} + if message_ids: + emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) + emo_res = await self.db.execute(emo_query) + for emo in emo_res.scalars().all(): + emotion_map[emo.message_id] = emo + + return format_conversation(conv, emotion_map) + + async def send_message( + self, conversation_id: str, payload: SendMessageRequest + ) -> SentMessageResponse: + """Send and persist a new user text message in an ongoing conversation session.""" + try: + c_uuid = uuid.UUID(conversation_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid conversation ID") + + query = ( + select(Conversation) + .options(selectinload(Conversation.messages)) + .where(Conversation.id == c_uuid) + ) + res = await self.db.execute(query) + conv = res.scalar_one_or_none() + + if not conv: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") + + next_seq = len(conv.messages) + 1 + user_msg = Message( + conversation_id=conv.id, + role="user", + content=payload.content, + message_type=MessageType.TEXT, + sequence_number=next_seq, + ) + self.db.add(user_msg) + await self.db.commit() + await self.db.refresh(user_msg) + + if payload.content and payload.content.strip(): + try: + analysis_result = await MLEmotionDetectorService.predict_message_emotion(payload.content) + await MLEmotionDetectorService.save_emotion_to_db(user_msg.id, analysis_result, self.db) + logger.info(f"🧠 [CHAT EMOTION PERSISTED]: Saved emotion for message {user_msg.id}") + except Exception as emo_err: + logger.warning(f"⚠️ [CHAT EMOTION EXCEPTION]: Failed to save emotion analysis: {emo_err}") + + return SentMessageResponse( + id=str(user_msg.id), + sender="user", + text=user_msg.content, + time="Sekarang", + isAudio=False, + ) + + +# Backward-compatible aliases +_format_time_local = format_time_local +_format_duration_str = format_duration_str +_analyze_message_emotion_fallback = analyze_message_emotion_fallback +_get_message_emotion = get_message_emotion +_format_conversation = format_conversation diff --git a/apps/backend/api/app/services/conversation/summary_stream_service.py b/apps/backend/api/app/services/conversation/summary_stream_service.py new file mode 100644 index 0000000..2a4b2c5 --- /dev/null +++ b/apps/backend/api/app/services/conversation/summary_stream_service.py @@ -0,0 +1,167 @@ +from collections.abc import AsyncGenerator +import json +import logging +from typing import Any +import uuid + +from fastapi import HTTPException, status +from fastapi.responses import StreamingResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.session import AsyncSessionLocal +from app.models.conversation import Conversation, ConversationSummary +from packages.ai.factories.llm_factory import LLMFactory +from packages.ai.interfaces.llm import LLMMessage + +logger = logging.getLogger(__name__) + +CRISIS_KEYWORDS = ( + "bunuh diri", "capek hidup", "mati saja", "ingin mati", "putus asa", + "menyakiti diri", "gantung diri", "akhiri hidup", "tak sanggup lagi", + "tidak sanggup hidup" +) + + +class SummaryStreamService: + """Service to generate and stream empathetic counseling summaries via SSE and persist to database.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def stream_summary( + self, conversation_id: uuid.UUID, user_id: uuid.UUID + ) -> StreamingResponse: + """Stream 2-3 sentence empathetic counseling summary token-by-token using SSE.""" + query = ( + select(Conversation) + .options( + selectinload(Conversation.messages), + selectinload(Conversation.summaries), + ) + .where(Conversation.id == conversation_id, Conversation.user_id == user_id) + ) + res = await self.db.execute(query) + conv = res.scalar_one_or_none() + if not conv: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Percakapan tidak ditemukan", + ) + + # 1. If already persisted in DB, yield cached summary immediately + if conv.summaries and conv.summaries[-1].summary: + existing_summary = conv.summaries[-1].summary + + async def cached_stream() -> AsyncGenerator[str, None]: + yield f"data: {json.dumps({'token': existing_summary})}\n\n" + yield f"data: {json.dumps({'done': True, 'summary': existing_summary})}\n\n" + + return StreamingResponse( + cached_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + # 2. Extract user and assistant messages for transcript + msgs = sorted(conv.messages, key=lambda m: m.created_at) if conv.messages else [] + user_assistant_msgs = [ + m for m in msgs if m.role in ("user", "assistant") and m.content and m.content.strip() + ] + + transcript_text = "\n".join([f"{m.role.upper()}: {m.content}" for m in user_assistant_msgs]) + full_lower = transcript_text.lower() + has_crisis = any(kw in full_lower for kw in CRISIS_KEYWORDS) + + if not user_assistant_msgs: + fallback_summary = "Sesi percakapan curhat bersama LUNA berjalan dengan tenang dan aman." + + async def empty_stream() -> AsyncGenerator[str, None]: + yield f"data: {json.dumps({'token': fallback_summary})}\n\n" + yield f"data: {json.dumps({'done': True, 'summary': fallback_summary})}\n\n" + + return StreamingResponse( + empty_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + system_prompt = LLMMessage( + role="system", + content=( + "Anda adalah konselor psikologis profesional LUNA AI. " + "Tugas Anda adalah membuat ringkasan percakapan konseling yang sangat ringkas, padat, dan menenangkan (tepat 2-3 kalimat Bahasa Indonesia).\n" + "Pedoman wajib:\n" + "1. Fokus pada esensi perasaan yang dicurahkan pengguna dan pendampingan reflektif dari LUNA.\n" + "2. Gunakan nada bicara hangat, empatik, tenang, dan suportif.\n" + "3. DILARANG KERAS mengutip mentah kata-kata pengguna dalam tanda kutip.\n" + "4. Jika terdeteksi kondisi krisis berat, berikan narasi suportif yang menekankan ruang aman dan pentingnya pertolongan profesional.\n" + "5. Hasilkan HANYA teks ringkasan langsung tanpa judul, tanpa pembuka/penutup, dan tanpa format markdown." + ), + ) + user_prompt = LLMMessage( + role="user", + content=f"Berikut transkrip percakapan suara:\n\n{transcript_text}\n\nBuat ringkasan konseling 2-3 kalimat:", + ) + + async def generate_stream() -> AsyncGenerator[str, None]: + full_tokens: list[str] = [] + try: + llm_provider = LLMFactory.get_provider() + async for token in llm_provider.stream_response([system_prompt, user_prompt]): + full_tokens.append(token) + yield f"data: {json.dumps({'token': token})}\n\n" + except Exception as e: + logger.error(f"⚠️ [SUMMARY STREAM LLM EXCEPTION]: {e}") + fallback_text = ( + "Pengguna meluangkan waktu untuk mengekspresikan beban emosional yang berat dalam sesi ini. " + "LUNA hadir mendampingi secara suportif, memberikan ruang aman untuk bercerita, " + "dan menegaskan bahwa setiap perasaan berharga serta bantuan selalu tersedia." + if has_crisis + else "Pengguna berbagi refleksi harian dan tantangan perasaan secara terbuka. " + "LUNA memberikan tanggapan yang menenangkan serta ruang aman untuk meredakan ketegangan." + ) + if not full_tokens: + yield f"data: {json.dumps({'token': fallback_text})}\n\n" + full_tokens.append(fallback_text) + + full_summary = "".join(full_tokens).strip() + if full_summary.startswith('"') and full_summary.endswith('"') and len(full_summary) > 2: + full_summary = full_summary[1:-1].strip() + + if full_summary: + try: + async with AsyncSessionLocal() as db_stream: + new_summary = ConversationSummary( + conversation_id=conversation_id, + summary=full_summary, + ) + db_stream.add(new_summary) + await db_stream.commit() + logger.info(f"✅ [SUMMARY STREAM PERSISTED]: Conversation {conversation_id}") + except Exception as db_err: + logger.error(f"⚠️ [FAILED TO PERSIST STREAM SUMMARY]: {db_err}") + + dominant_emotion = "Krisis / Sangat Tertekan" if has_crisis else None + dominant_emoji = "🆘" if has_crisis else None + + yield f"data: {json.dumps({'done': True, 'summary': full_summary, 'dominant_emotion': dominant_emotion, 'dominant_emoji': dominant_emoji})}\n\n" + + return StreamingResponse( + generate_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/apps/backend/api/app/services/conversation_service.py b/apps/backend/api/app/services/conversation_service.py deleted file mode 100644 index 461a7fb..0000000 --- a/apps/backend/api/app/services/conversation_service.py +++ /dev/null @@ -1,188 +0,0 @@ -import uuid -from collections import Counter -from datetime import datetime, timezone -from typing import Any - -from app.core.tz import to_wib -from app.models.conversation import Conversation, Message -from app.models.enums import MessageType -from app.models.safety import EmotionAnalysis - - -def format_time_local(dt: datetime | None) -> str: - """Format datetime into 12-hour local time format (hh:mm AM/PM).""" - if not dt: - return "09:15 AM" - try: - dt_wib = to_wib(dt) - return dt_wib.strftime("%I:%M %p") if dt_wib else "09:15 AM" - except Exception: - return dt.strftime("%I:%M %p") - - -def format_duration_str(started_at: datetime | None, last_message_at: datetime | None) -> str: - """Calculate elapsed session duration and format as MM:SS.""" - if not started_at or not last_message_at: - return "00:00" - try: - t1 = started_at if started_at.tzinfo else started_at.replace(tzinfo=timezone.utc) - t2 = last_message_at if last_message_at.tzinfo else last_message_at.replace(tzinfo=timezone.utc) - diff = int((t2 - t1).total_seconds()) - if diff < 0: - diff = 0 - mins = diff // 60 - secs = diff % 60 - return f"{mins:02d}:{secs:02d}" - except Exception: - return "00:00" - - -def analyze_message_emotion_fallback(text: str) -> tuple[str, str]: - """Determine emotion tag and emoji dynamically from message text keywords.""" - t_low = (text or "").lower() - anx_words = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup", "takutnya", "bingung", "was-was", "tegang"] - dep_words = ["sedih", "nangis", "menangis", "hampa", "sendiri", "kehilangan", "terpuruk", "putus asa", "kecewa", "patah hati", "bunuh diri"] - str_words = ["stres", "capek", "lelah", "beban", "berat", "penat", "pusing", "mumet", "tekanan", "tugas", "deadline", "kerjaan", "letih"] - ang_words = ["marah", "kesal", "jengkel", "benci", "emosi", "sebal", "kesel", "geram", "murka"] - pos_words = ["lega", "senang", "bahagia", "terima kasih", "makasih", "enakan", "tenang", "nyaman", "santai", "alhamdulillah", "syukurlah", "baik", "bersyukur"] - - if any(w in t_low for w in anx_words): - return "fear (78%)", "😨" - if any(w in t_low for w in dep_words): - return "sadness (82%)", "😔" - if any(w in t_low for w in str_words): - return "stress (75%)", "💥" - if any(w in t_low for w in ang_words): - return "anger (70%)", "😡" - if any(w in t_low for w in pos_words): - return "calm (85%)", "😌" - - return "netral (60%)", "😐" - - -def get_message_emotion( - m: Message, emotion_map: dict[uuid.UUID, EmotionAnalysis] | None = None -) -> tuple[str, str]: - """Retrieve emotion tag and emoji for a message from ML emotion map or keyword heuristic.""" - if m.role not in ("user",): - return "", "" - - if emotion_map and m.id in emotion_map: - emo = emotion_map[m.id] - if emo and emo.primary_emotion: - pct = int((float(emo.confidence) if emo.confidence else 0.8) * 100) - emo_lower = emo.primary_emotion.lower() - name_map = { - "fearful": "fear", - "sad": "sadness", - "angry": "anger", - "neutral": "netral", - "happy": "joy", - } - display_name = name_map.get(emo_lower, emo_lower) - emoji_map = { - "happy": "😃", "joy": "😃", "senang": "😃", "bahagia": "😃", - "calm": "😌", "tenang": "😌", "lega": "😌", - "fearful": "😨", "fear": "😨", "cemas": "😨", "takut": "😨", - "sad": "😔", "sadness": "😔", "sedih": "😔", - "angry": "😡", "anger": "😡", "marah": "😡", - "stress": "💥", "stres": "💥", - "neutral": "😐", "netral": "😐", - } - return f"{display_name} ({pct}%)", emoji_map.get(emo_lower, "🌱") - - return analyze_message_emotion_fallback(m.content) - - -def format_conversation( - c: Conversation, emotion_map: dict[uuid.UUID, EmotionAnalysis] | None = None -) -> dict[str, Any]: - """Format a conversation entity into a detailed API response dictionary.""" - msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] - last_msg = msgs[-1].content if msgs else "" - formatted_msgs = [] - user_emotions: list[tuple[str, str]] = [] - for m in msgs: - emo_tag, emo_emoji = get_message_emotion(m, emotion_map) - if m.role in ("user",) and emo_tag: - user_emotions.append((emo_tag, emo_emoji)) - formatted_msgs.append({ - "id": str(m.id), - "sender": "eva" if m.role in ("assistant", "luna", "eva") else "user", - "text": m.content, - "time": format_time_local(m.created_at), - "isAudio": m.message_type in (MessageType.VOICE, "voice", "audio"), - "emotionTag": emo_tag, - "emotionEmoji": emo_emoji, - }) - - dominant_label = "Tenang & Nyaman" - dominant_emoji = "😌" - if user_emotions: - counts: Counter[str] = Counter() - emoji_by_name: dict[str, str] = {} - for tag, emoji in user_emotions: - name = tag.split()[0].lower() if tag else "netral" - counts[name] += 1 - if emoji: - emoji_by_name[name] = emoji - - most_common_name, _ = counts.most_common(1)[0] - name_map = { - "happy": "Lega & Senang", - "joy": "Lega & Senang", - "calm": "Tenang & Damai", - "fear": "Cemas & Takut", - "fearful": "Cemas & Takut", - "anxiety": "Cemas & Gelisah", - "sadness": "Sedih & Hampa", - "sad": "Sedih & Hampa", - "depression": "Sedih & Tertekan", - "stress": "Stres & Lelah", - "anger": "Marah & Kesal", - "angry": "Marah & Kesal", - "neutral": "Netral & Rileks", - "netral": "Netral & Rileks", - } - dominant_label = name_map.get(most_common_name, most_common_name.capitalize()) - dominant_emoji = emoji_by_name.get(most_common_name, "😌") - - # Safety/Crisis Override: Jangan pernah kategorikan krisis atau niat menyakiti diri sebagai Netral & Rileks - crisis_keywords = ( - "bunuh diri", "capek hidup", "mati saja", "ingin mati", "putus asa", - "menyakiti diri", "gantung diri", "akhiri hidup", "tak sanggup lagi", - "tidak sanggup hidup" - ) - all_user_text = " ".join([m.content.lower() for m in msgs if m.role == "user" and m.content]) - if any(kw in all_user_text for kw in crisis_keywords): - dominant_label = "Krisis / Sangat Tertekan" - dominant_emoji = "🆘" - - end_time = c.last_message_at or (msgs[-1].created_at if msgs else c.started_at) - duration_str = format_duration_str(c.started_at, end_time) - - # Return summary from DB if exists; otherwise None to trigger client-side skeleton & stream - summary_text = None - if hasattr(c, "summaries") and c.summaries: - summary_text = c.summaries[-1].summary - - return { - "id": str(c.id), - "title": c.title or "Sesi Percakapan", - "is_title_generating": "$skeleton" in (c.title or ""), - "lastMessage": last_msg, - "lastMessageTime": format_time_local(c.started_at), - "duration": duration_str, - "dominant_emotion": dominant_label, - "dominant_emoji": dominant_emoji, - "summary": summary_text, - "messages": formatted_msgs, - } - - -# Backwards-compatible aliases -_format_time_local = format_time_local -_format_duration_str = format_duration_str -_analyze_message_emotion_fallback = analyze_message_emotion_fallback -_get_message_emotion = get_message_emotion -_format_conversation = format_conversation diff --git a/apps/backend/api/app/services/dass/__init__.py b/apps/backend/api/app/services/dass/__init__.py new file mode 100644 index 0000000..0f41e9d --- /dev/null +++ b/apps/backend/api/app/services/dass/__init__.py @@ -0,0 +1,3 @@ +from app.services.dass.dass_service import DASSService, OFFICIAL_DASS21_ITEMS + +__all__ = ["DASSService", "OFFICIAL_DASS21_ITEMS"] diff --git a/apps/backend/api/app/services/dass_service.py b/apps/backend/api/app/services/dass/dass_service.py similarity index 78% rename from apps/backend/api/app/services/dass_service.py rename to apps/backend/api/app/services/dass/dass_service.py index c0f9a3d..95207d7 100644 --- a/apps/backend/api/app/services/dass_service.py +++ b/apps/backend/api/app/services/dass/dass_service.py @@ -1,12 +1,16 @@ import logging -import uuid from datetime import date from typing import Any +import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from sqlalchemy.orm.attributes import flag_modified +from ai.services.dass_extraction_service import DASSExtractionService +from app.core.tz import get_wib_day_range_utc, get_wib_today +from app.models.conversation import Conversation from app.models.dass import DASSAssessment from app.schemas.dass import ( DASSAssessmentResponse, @@ -42,6 +46,11 @@ class DASSService: + """Service to manage DASS-21 psychological assessments, automated AI extraction, and score calculations.""" + + def __init__(self, db: AsyncSession): + self.db = db + @staticmethod def generate_default_items() -> list[dict[str, Any]]: """Menghasilkan template 21 butir resmi dengan nilai default 0.""" @@ -62,7 +71,6 @@ def generate_default_items() -> list[dict[str, Any]]: @staticmethod def calculate_scores(items: list[dict[str, Any]]) -> dict[str, Any]: """Menghitung total skor per subskala (dikali 2 sesuai norma DASS-21 Indonesia) - dan mengklasifikasikan ke dalam kategori keparahan. """ dep_raw = sum(int(it.get("score", 0)) for it in items if it.get("scale") == "depression") @@ -118,29 +126,23 @@ def calculate_scores(items: list[dict[str, Any]]) -> dict[str, Any]: "stress_severity": str_sev, } - @staticmethod - async def get_today_assessment( - user_id: uuid.UUID, - today_date: date, - db: AsyncSession, - ) -> DASSAssessmentResponse: + async def get_today_assessment(self, user_id: uuid.UUID) -> DASSAssessmentResponse: """Mengambil asesmen DASS hari ini. Jika belum ada di database, - mengembalikan form default 21 butir (skor 0) agar mobile tetap bisa menampilkan form. """ + today_date = get_wib_today() stmt = select(DASSAssessment).where( DASSAssessment.user_id == user_id, DASSAssessment.assessed_date == today_date, ) - res = await db.execute(stmt) + res = await self.db.execute(stmt) record = res.scalars().first() if record: return DASSAssessmentResponse.model_validate(record) - # Kembalikan template belum dinilai - default_items = DASSService.generate_default_items() - scores = DASSService.calculate_scores(default_items) + default_items = self.generate_default_items() + scores = self.calculate_scores(default_items) return DASSAssessmentResponse( id=None, @@ -158,37 +160,125 @@ async def get_today_assessment( items=[DASSItemSchema(**it) for it in default_items], ) - @staticmethod + async def update_user_assessment( + self, + user_id: uuid.UUID, + updates: list[DASSItemUpdate], + assessment_id: uuid.UUID | None = None, + ) -> DASSAssessmentResponse: + """Memperbarui skor butir yang diedit oleh pengguna, menghitung ulang skor subskala, + dan menandai record sebagai 'verified'. + """ + today_date = get_wib_today() + stmt = select(DASSAssessment).where( + DASSAssessment.user_id == user_id, + DASSAssessment.assessed_date == today_date, + ) + res = await self.db.execute(stmt) + record = res.scalars().first() + + if not record: + base_items = self.generate_default_items() + record = DASSAssessment( + user_id=user_id, + assessed_date=today_date, + verified_by_user=True, + status="verified", + items=base_items, + ) + self.db.add(record) + + current_items = list(record.items) if record.items else self.generate_default_items() + item_dict_map = {it["item_id"]: it for it in current_items} + + for up in updates: + if up.item_id in item_dict_map: + item_dict_map[up.item_id]["score"] = up.score + item_dict_map[up.item_id]["is_user_edited"] = True + + new_items = list(item_dict_map.values()) + scores = self.calculate_scores(new_items) + + record.items = new_items + flag_modified(record, "items") + record.depression_score = scores["depression_score"] + record.anxiety_score = scores["anxiety_score"] + record.stress_score = scores["stress_score"] + record.depression_severity = scores["depression_severity"] + record.anxiety_severity = scores["anxiety_severity"] + record.stress_severity = scores["stress_severity"] + record.verified_by_user = True + record.status = "verified" + + await self.db.commit() + await self.db.refresh(record) + return DASSAssessmentResponse.model_validate(record) + + async def extract_today_assessment(self, user_id: uuid.UUID) -> DASSAssessmentResponse: + """Trigger AI extraction of 21 DASS items from today's conversation transcripts.""" + today_wib = get_wib_today() + start_utc, end_utc = get_wib_day_range_utc(today_wib) + + conv_stmt = ( + select(Conversation) + .where( + Conversation.user_id == user_id, + Conversation.created_at >= start_utc, + Conversation.created_at <= end_utc, + ) + .options(selectinload(Conversation.messages)) + ) + conv_res = await self.db.execute(conv_stmt) + conversations = conv_res.scalars().all() + + formatted_msgs = [] + latest_conv_id = None + for c in conversations: + latest_conv_id = c.id + for m in sorted(c.messages, key=lambda x: x.created_at): + if m.content and m.content.strip(): + formatted_msgs.append(f"{m.role.upper()}: {m.content}") + + transcript = "\n".join(formatted_msgs) + extractor = DASSExtractionService() + extracted_items = await extractor.extract_from_transcript(transcript) + + saved_record = await self.save_or_update_extracted( + user_id=user_id, + assessed_date=today_wib, + extracted_items=extracted_items, + conversation_id=latest_conv_id, + ) + return DASSAssessmentResponse.model_validate(saved_record) + async def save_or_update_extracted( + self, user_id: uuid.UUID, assessed_date: date, extracted_items: list[dict[str, Any]], conversation_id: uuid.UUID | None, - db: AsyncSession, ) -> DASSAssessment: """Menyimpan atau memperbarui hasil ekstraksi AI ke tabel dass_assessments.""" - # Gabungkan dengan template lengkap 21 butir jika extracted_items belum lengkap item_map = {it["item_id"]: it for it in extracted_items} complete_items = [] - for default_it in DASSService.generate_default_items(): + for default_it in self.generate_default_items(): i_id = default_it["item_id"] if i_id in item_map: complete_items.append(item_map[i_id]) else: complete_items.append(default_it) - scores = DASSService.calculate_scores(complete_items) + scores = self.calculate_scores(complete_items) stmt = select(DASSAssessment).where( DASSAssessment.user_id == user_id, DASSAssessment.assessed_date == assessed_date, ) - res = await db.execute(stmt) + res = await self.db.execute(stmt) record = res.scalars().first() if record: - # Jika user sudah memverifikasi manual, jangan ditimpa oleh auto_extracted if not record.verified_by_user: record.depression_score = scores["depression_score"] record.anxiety_score = scores["anxiety_score"] @@ -215,64 +305,8 @@ async def save_or_update_extracted( status="auto_extracted", items=complete_items, ) - db.add(record) + self.db.add(record) - await db.commit() - await db.refresh(record) + await self.db.commit() + await self.db.refresh(record) return record - - @staticmethod - async def update_user_assessment( - user_id: uuid.UUID, - today_date: date, - updates: list[DASSItemUpdate], - db: AsyncSession, - ) -> DASSAssessmentResponse: - """Memperbarui skor butir yang diedit oleh pengguna, menghitung ulang skor subskala, - - dan menandai record sebagai 'verified'. - """ - stmt = select(DASSAssessment).where( - DASSAssessment.user_id == user_id, - DASSAssessment.assessed_date == today_date, - ) - res = await db.execute(stmt) - record = res.scalars().first() - - if not record: - # Jika belum ada record tersimpan di DB, buat baru dari default items - base_items = DASSService.generate_default_items() - record = DASSAssessment( - user_id=user_id, - assessed_date=today_date, - verified_by_user=True, - status="verified", - items=base_items, - ) - db.add(record) - - current_items = list(record.items) if record.items else DASSService.generate_default_items() - item_dict_map = {it["item_id"]: it for it in current_items} - - for up in updates: - if up.item_id in item_dict_map: - item_dict_map[up.item_id]["score"] = up.score - item_dict_map[up.item_id]["is_user_edited"] = True - - new_items = list(item_dict_map.values()) - scores = DASSService.calculate_scores(new_items) - - record.items = new_items - flag_modified(record, "items") - record.depression_score = scores["depression_score"] - record.anxiety_score = scores["anxiety_score"] - record.stress_score = scores["stress_score"] - record.depression_severity = scores["depression_severity"] - record.anxiety_severity = scores["anxiety_severity"] - record.stress_severity = scores["stress_severity"] - record.verified_by_user = True - record.status = "verified" - - await db.commit() - await db.refresh(record) - return DASSAssessmentResponse.model_validate(record) diff --git a/apps/backend/api/app/services/diary/__init__.py b/apps/backend/api/app/services/diary/__init__.py new file mode 100644 index 0000000..1c62d52 --- /dev/null +++ b/apps/backend/api/app/services/diary/__init__.py @@ -0,0 +1,4 @@ +from app.services.diary.diary_generator import DiaryGeneratorService +from app.services.diary.diary_service import DiaryService + +__all__ = ["DiaryService", "DiaryGeneratorService"] diff --git a/apps/backend/api/app/services/diary_generator.py b/apps/backend/api/app/services/diary/diary_generator.py similarity index 98% rename from apps/backend/api/app/services/diary_generator.py rename to apps/backend/api/app/services/diary/diary_generator.py index f04ca53..1750cfa 100644 --- a/apps/backend/api/app/services/diary_generator.py +++ b/apps/backend/api/app/services/diary/diary_generator.py @@ -1,17 +1,16 @@ import json import logging -from datetime import date, datetime, timezone +from datetime import date from typing import Any import uuid -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.tz import get_wib_day_range_utc, get_wib_now, get_wib_today, to_wib from app.models.conversation import Conversation from app.models.diary import DiaryEntry -from app.models.user import User from packages.ai.factories.llm_factory import LLMFactory from packages.ai.interfaces.llm import LLMMessage diff --git a/apps/backend/api/app/services/diary/diary_service.py b/apps/backend/api/app/services/diary/diary_service.py new file mode 100644 index 0000000..aaf0f0c --- /dev/null +++ b/apps/backend/api/app/services/diary/diary_service.py @@ -0,0 +1,358 @@ +from datetime import date +import logging +from typing import Any +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.tz import get_wib_day_range_utc, get_wib_today, to_wib +from app.models.conversation import Conversation +from app.models.diary import DiaryEntry +from app.schemas.diary import ( + CreateDiaryRequest, + DeleteDiaryResponse, + DiaryEntryResponse, + DiaryRiskWarning, + DiarySessionItem, + EmotionBreakdownItem, + TranscriptItem, +) +from app.services.diary.diary_generator import DiaryGeneratorService + +logger = logging.getLogger(__name__) + + +class DiaryService: + """Service handling diary CRUD, emotion analysis breakdown, and session transcript aggregation.""" + + def __init__(self, db: AsyncSession): + self.db = db + + @staticmethod + def _analyze_message_emotion(text: str, is_user: bool) -> tuple[str, str]: + """Dynamically determine emotion tag and emoji from individual message content.""" + if not is_user: + return "empathy", "💙" + + t_low = text.lower() + anx_words = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup", "takutnya", "bingung"] + dep_words = ["sedih", "nangis", "menangis", "hampa", "sendiri", "kehilangan", "terpuruk", "putus asa", "kecewa", "patah hati", "bunuh diri"] + str_words = ["stres", "capek", "lelah", "beban", "berat", "penat", "pusing", "mumet", "tekanan", "tugas", "deadline", "kerjaan"] + ang_words = ["marah", "kesal", "jengkel", "benci", "emosi", "sebal", "kesel"] + pos_words = ["lega", "senang", "bahagia", "terima kasih", "makasih", "enakan", "tenang", "nyaman", "santai", "alhamdulillah", "syukurlah", "baik"] + + if any(w in t_low for w in anx_words): + return "fear (78%)", "😨" + if any(w in t_low for w in dep_words): + return "sadness (82%)", "😔" + if any(w in t_low for w in str_words): + return "stress (75%)", "💥" + if any(w in t_low for w in ang_words): + return "anger (70%)", "😡" + if any(w in t_low for w in pos_words): + return "calm (85%)", "😌" + + return "netral (60%)", "😐" + + @staticmethod + def _calculate_session_emotions( + transcripts: list[TranscriptItem], + session_title: str, + default_breakdown: list[EmotionBreakdownItem], + ) -> list[EmotionBreakdownItem]: + combined_text = (session_title + " " + " ".join(t.text for t in transcripts if t.isUser)).lower() + + anx_keywords = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup"] + dep_keywords = ["bunuh diri", "depresi", "sedih", "sendiri", "kehilangan", "hampa", "nangis", "menangis", "terpuruk"] + str_keywords = ["stres", "capek", "lelah", "beban", "berat", "penat", "tugas", "kerja"] + pos_keywords = ["lega", "senang", "terima kasih", "enakan", "tenang", "nyaman", "tidur", "santai", "baik"] + + anx_count = sum(1 for kw in anx_keywords if kw in combined_text) + dep_count = sum(1 for kw in dep_keywords if kw in combined_text) + str_count = sum(1 for kw in str_keywords if kw in combined_text) + pos_count = sum(1 for kw in pos_keywords if kw in combined_text) + + if anx_count == 0 and dep_count == 0 and str_count == 0 and pos_count == 0: + return default_breakdown + + raw = [ + {"name": "fear", "label": "Takut / Gelisah", "emoji": "😨", "percent": max(0.04, anx_count * 0.25), "color": "#6C63FF"}, + {"name": "sadness", "label": "Sedih / Haru", "emoji": "😔", "percent": max(0.04, dep_count * 0.25), "color": "#8B93FF"}, + {"name": "netral", "label": "Netral", "emoji": "😐", "percent": 0.08, "color": "#A7E6FF"}, + {"name": "stress", "label": "Stres / Tertekan", "emoji": "💥", "percent": max(0.04, str_count * 0.20), "color": "#FF8A80"}, + {"name": "happy", "label": "Bahagia", "emoji": "😃", "percent": max(0.03, pos_count * 0.15), "color": "#FFE6A7"}, + {"name": "surprise", "label": "Terkejut", "emoji": "😲", "percent": 0.02, "color": "#C3B8FF"}, + {"name": "anger", "label": "Marah", "emoji": "😡", "percent": 0.01, "color": "#FFB6C1"}, + ] + tot = sum(item["percent"] for item in raw) + return [ + EmotionBreakdownItem( + name=item["name"], + label=item["label"], + emoji=item["emoji"], + percent=round(item["percent"] / tot, 2), + color=item["color"], + ) + for item in raw + ] + + @classmethod + def format_diary_entry(cls, d: DiaryEntry | None) -> DiaryEntryResponse: + """Format a DiaryEntry SQLAlchemy model into a structured DiaryEntryResponse DTO.""" + if not d: + return DiaryEntryResponse() + + raw_events = d.important_events if isinstance(d.important_events, list) else [] + events = raw_events if raw_events else ["Sesi refleksi harian tercatat dalam sistem LUNA."] + + scores = d.mental_health_scores or {} + dep = float(scores.get("depression", 0.25)) + anx = float(scores.get("anxiety", 0.45)) + strs = float(scores.get("stress", 0.15)) + + raw_breakdown = [ + {"name": "fear", "label": "Takut / Gelisah", "emoji": "😨", "percent": max(0.05, anx), "color": "#6C63FF"}, + {"name": "sadness", "label": "Sedih / Haru", "emoji": "😔", "percent": max(0.05, dep), "color": "#8B93FF"}, + {"name": "netral", "label": "Netral", "emoji": "😐", "percent": 0.15, "color": "#A7E6FF"}, + {"name": "stress", "label": "Stres / Tertekan", "emoji": "💥", "percent": max(0.05, strs), "color": "#FF8A80"}, + {"name": "happy", "label": "Bahagia", "emoji": "😃", "percent": 0.10, "color": "#FFE6A7"}, + {"name": "surprise", "label": "Terkejut", "emoji": "😲", "percent": 0.03, "color": "#C3B8FF"}, + {"name": "anger", "label": "Marah", "emoji": "😡", "percent": 0.02, "color": "#FFB6C1"}, + ] + tot = sum(item["percent"] for item in raw_breakdown) + emotions_breakdown = [ + EmotionBreakdownItem( + name=item["name"], + label=item["label"], + emoji=item["emoji"], + percent=round(item["percent"] / tot, 2), + color=item["color"], + ) + for item in raw_breakdown + ] + + mood_tag = d.mood_tag or "Netral" + has_risk = any(kw in mood_tag for kw in ("Darurat", "Stres", "Cemas", "Sedih")) + risk_warning = ( + DiaryRiskWarning( + detected=True, + type="High Risk / Krisis", + title="PERINGATAN KRISIS EMOSIONAL", + level="RISIKO TINGGI", + message="Sistem LUNA mendeteksi akumulasi indikasi krisis emosional tinggi dan stres berat pada percakapan hari ini. Protokol keselamatan aktif untuk rujukan darurat 119 ext 8.", + ) + if has_risk + else None + ) + + return DiaryEntryResponse( + id=str(d.id), + title=d.title or "Refleksi Harian LUNA", + date=d.entry_date.strftime("%d %B %Y") if isinstance(d.entry_date, date) else str(d.entry_date), + sessionCount=0, + lastSessionTime="-", + moodTag=mood_tag, + moodEmoji=d.mood_emoji or "😌", + summary=d.summary or "Catatan harian perkembangan emosional bersama LUNA.", + riskWarning=risk_warning, + aiInsight=d.ai_insight or "Analisis AI menunjukkan kondisi stabil.", + importantEvents=events, + emotionalReflection=d.emotional_reflection or "Merasa tenang setelah refleksi.", + mentalHealthScores=scores, + emotionsBreakdown=emotions_breakdown, + sessions=[], + ) + + async def _attach_sessions( + self, formatted: DiaryEntryResponse, entry: DiaryEntry + ) -> DiaryEntryResponse: + """Attach real conversation transcripts and breakdown for the diary's date.""" + if not isinstance(entry.entry_date, date): + return formatted + + start_utc, end_utc = get_wib_day_range_utc(entry.entry_date) + conv_query = ( + select(Conversation) + .options(selectinload(Conversation.messages)) + .where( + Conversation.user_id == entry.user_id, + Conversation.started_at >= start_utc, + Conversation.started_at <= end_utc, + ) + .order_by(Conversation.started_at.asc()) + ) + conv_res = await self.db.execute(conv_query) + convs = conv_res.scalars().all() + + sessions_data: list[DiarySessionItem] = [] + for c in convs: + sorted_msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] + sess_transcripts: list[TranscriptItem] = [] + for m in sorted_msgs: + if m.role in ("user", "assistant") and m.content: + msg_t = to_wib(m.created_at) + emo_tag, emo_emoji = self._analyze_message_emotion(m.content, m.role == "user") + sess_transcripts.append( + TranscriptItem( + isUser=m.role == "user", + time=msg_t.strftime("%H:%M") if msg_t else "09:00", + text=m.content, + emotionTag=emo_tag, + emotionEmoji=emo_emoji, + ) + ) + + if not sess_transcripts: + continue + + c_t = to_wib(c.started_at) + time_str = c_t.strftime("%H:%M") if c_t else "09:00" + title = (c.title or "").replace("$skeleton", "").strip() + if not title: + title = f"Sesi #{len(sessions_data) + 1} Percakapan Suara" + + sess_emotions = self._calculate_session_emotions( + sess_transcripts, + title, + formatted.emotionsBreakdown, + ) + + sessions_data.append( + DiarySessionItem( + id=str(c.id), + title=title, + time=time_str, + moodTag=entry.mood_tag or "Netral", + moodEmoji=entry.mood_emoji or "😌", + emotionsBreakdown=sess_emotions, + transcripts=sess_transcripts, + ) + ) + + formatted.sessions = sessions_data + formatted.sessionCount = len(sessions_data) + formatted.lastSessionTime = sessions_data[-1].time if sessions_data else "-" + return formatted + + async def get_diaries( + self, user_id: uuid.UUID, mood: str | None = None, search: str | None = None + ) -> list[DiaryEntryResponse]: + """Fetch all diary entries for a user, optionally filtered by mood tag or search term.""" + query = select(DiaryEntry).where(DiaryEntry.user_id == user_id).order_by(DiaryEntry.entry_date.desc()) + res = await self.db.execute(query) + entries = res.scalars().all() + + formatted_list: list[DiaryEntryResponse] = [] + for e in entries: + if e is not None: + f = self.format_diary_entry(e) + f = await self._attach_sessions(f, e) + formatted_list.append(f) + + if mood and mood != "Semua": + clean_mood = mood.lower() + formatted_list = [f for f in formatted_list if clean_mood in (f.moodTag or "").lower()] + + if search: + clean_q = search.lower() + formatted_list = [ + f for f in formatted_list + if clean_q in (f.title or "").lower() or clean_q in (f.summary or "").lower() + ] + + return formatted_list + + async def get_today_diary(self, user_id: uuid.UUID) -> DiaryEntryResponse: + """Fetch today's diary entry, generating a new one via LLM if not yet present.""" + today = get_wib_today() + query = select(DiaryEntry).where(DiaryEntry.user_id == user_id, DiaryEntry.entry_date == today) + res = await self.db.execute(query) + entry = res.scalar_one_or_none() + + if not entry: + entry = await DiaryGeneratorService.generate_today_diary(user_id, self.db) + + if not entry: + return DiaryEntryResponse(id=None, message="Belum ada jurnal untuk hari ini.") + + formatted = self.format_diary_entry(entry) + return await self._attach_sessions(formatted, entry) + + async def get_diary_by_id(self, user_id: uuid.UUID, diary_id: str) -> DiaryEntryResponse: + """Fetch single diary entry by ID verifying user ownership.""" + try: + d_uuid = uuid.UUID(diary_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid diary ID") + + query = select(DiaryEntry).where( + DiaryEntry.id == d_uuid, + DiaryEntry.user_id == user_id, + ) + res = await self.db.execute(query) + entry = res.scalar_one_or_none() + + if not entry: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Diary entry not found") + + formatted = self.format_diary_entry(entry) + return await self._attach_sessions(formatted, entry) + + async def generate_today_diary(self, user_id: uuid.UUID) -> DiaryEntryResponse: + """Force manual generation or re-synthesis of today's diary.""" + entry = await DiaryGeneratorService.generate_today_diary(user_id, self.db) + return self.format_diary_entry(entry) + + async def create_diary(self, user_id: uuid.UUID, payload: CreateDiaryRequest) -> DiaryEntryResponse: + """Create or append today's custom diary entry reflection.""" + today = get_wib_today() + + query = select(DiaryEntry).where(DiaryEntry.user_id == user_id, DiaryEntry.entry_date == today) + res = await self.db.execute(query) + entry = res.scalar_one_or_none() + + if entry: + entry.summary += f"\n- {payload.content}" + entry.content += f"\n- {payload.content}" + if payload.mood_tag: + entry.mood_tag = payload.mood_tag + else: + entry = DiaryEntry( + user_id=user_id, + entry_date=today, + title="Refleksi Catatan Baru", + summary=payload.content, + content=payload.content, + mood_tag=payload.mood_tag or "Netral", + mood_emoji="😌", + ai_insight="Catatan refleksi berhasil disimpan.", + emotional_reflection="Merasa lega setelah menulis catatan.", + important_events=[f"[Sesi #1] {payload.content[:50]}"], + ) + self.db.add(entry) + + await self.db.commit() + await self.db.refresh(entry) + + return self.format_diary_entry(entry) + + async def delete_diary(self, diary_id: str) -> DeleteDiaryResponse: + """Delete a diary entry by ID.""" + try: + d_uuid = uuid.UUID(diary_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid diary ID") + + query = select(DiaryEntry).where(DiaryEntry.id == d_uuid) + res = await self.db.execute(query) + entry = res.scalar_one_or_none() + + if not entry: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Diary entry not found") + + await self.db.delete(entry) + await self.db.commit() + return DeleteDiaryResponse() diff --git a/apps/backend/api/app/services/emotion/__init__.py b/apps/backend/api/app/services/emotion/__init__.py new file mode 100644 index 0000000..9b2746e --- /dev/null +++ b/apps/backend/api/app/services/emotion/__init__.py @@ -0,0 +1,4 @@ +from app.services.emotion.analyzer import EmotionAnalyzerService +from app.services.emotion.ml_detector import MLEmotionDetectorService + +__all__ = ["MLEmotionDetectorService", "EmotionAnalyzerService"] diff --git a/apps/backend/api/app/services/emotion_analyzer.py b/apps/backend/api/app/services/emotion/analyzer.py similarity index 100% rename from apps/backend/api/app/services/emotion_analyzer.py rename to apps/backend/api/app/services/emotion/analyzer.py diff --git a/apps/backend/api/app/services/ml_emotion_detector.py b/apps/backend/api/app/services/emotion/ml_detector.py similarity index 100% rename from apps/backend/api/app/services/ml_emotion_detector.py rename to apps/backend/api/app/services/emotion/ml_detector.py diff --git a/apps/backend/api/app/services/recommendation/__init__.py b/apps/backend/api/app/services/recommendation/__init__.py new file mode 100644 index 0000000..1553067 --- /dev/null +++ b/apps/backend/api/app/services/recommendation/__init__.py @@ -0,0 +1,3 @@ +from app.services.recommendation.recommendation_service import RecommendationService + +__all__ = ["RecommendationService"] diff --git a/apps/backend/api/app/services/recommendation/recommendation_service.py b/apps/backend/api/app/services/recommendation/recommendation_service.py new file mode 100644 index 0000000..8ea8645 --- /dev/null +++ b/apps/backend/api/app/services/recommendation/recommendation_service.py @@ -0,0 +1,398 @@ +import logging +from typing import Any +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.tz import get_wib_today +from app.models.coping_activity import CopingActivity, UserActivityCompletion +from app.models.dass import DASSAssessment +from app.models.diary import DiaryEntry +from app.models.enums import SafetyEventStatus +from app.models.recommendation import RecommendationItem +from app.models.safety import SafetyEvent +from app.models.user import User +from app.schemas.recommendation import ( + CompleteActivityResponse, + CopingActivityResponse, + TodayRecommendationResponse, +) + +logger = logging.getLogger(__name__) + + +class RecommendationService: + """Service to evaluate mental health conditions (DASS, Safety Crisis, Diary) and recommend tailored coping activities.""" + + def __init__(self, db: AsyncSession): + self.db = db + + @staticmethod + def _map_act(act: CopingActivity, completed_ids: set[uuid.UUID]) -> CopingActivityResponse: + return CopingActivityResponse( + id=str(act.id), + title=act.title, + category=act.category, + targetCondition=act.target_condition, + duration=act.duration, + difficulty=act.difficulty, + level=act.difficulty, + description=act.description, + instructions=act.instructions or [], + rationale=act.rationale or "", + iconName=act.icon_name or "spa", + isCompleted=act.id in completed_ids, + ) + + async def get_today_recommendation(self, user_id: uuid.UUID) -> TodayRecommendationResponse: + """Evaluate today's condition and return recommended coping activities and crisis guidance.""" + today_wib = get_wib_today() + + # 1. Check open Crisis / Suicide Risk + is_crisis = False + crisis_hotline = "Kemenkes 119 ext 8 / LISA 0811-3855-472" + crisis_guidance = ( + "Layanan konseling mandiri AI dibatasi saat terindikasi krisis emosional tinggi. " + "Keselamatanmu adalah hal yang paling berharga. Tolong segera hubungi tenaga profesional atau orang terdekat sekarang juga." + ) + + safety_query = ( + select(SafetyEvent) + .where( + SafetyEvent.user_id == user_id, + SafetyEvent.status == SafetyEventStatus.OPEN, + SafetyEvent.severity.in_(["high", "critical"]), + ) + .order_by(desc(SafetyEvent.detected_at)) + .limit(1) + ) + res_safety = await self.db.execute(safety_query) + open_safety_event = res_safety.scalars().first() + + # 2. Fetch today's or most recent DASS-21 assessment + dass_query = ( + select(DASSAssessment) + .where(DASSAssessment.user_id == user_id) + .order_by(desc(DASSAssessment.assessed_date)) + .limit(1) + ) + res_dass = await self.db.execute(dass_query) + latest_dass = res_dass.scalars().first() + + # 3. Fetch today's or recent diary entry + diary_query = ( + select(DiaryEntry) + .where(DiaryEntry.user_id == user_id, DiaryEntry.entry_date == today_wib) + .order_by(desc(DiaryEntry.created_at)) + ) + res_diary = await self.db.execute(diary_query) + today_diary = res_diary.scalars().first() + + if not today_diary: + recent_query = ( + select(DiaryEntry) + .where(DiaryEntry.user_id == user_id) + .order_by(desc(DiaryEntry.entry_date)) + .limit(1) + ) + res_recent = await self.db.execute(recent_query) + today_diary = res_recent.scalars().first() + + # Check for suicide / self-harm indication in DASS item 21 + suicide_dass_detected = False + if latest_dass and latest_dass.items: + for it in latest_dass.items: + if it.get("item_id") == 21 and int(it.get("score", 0)) >= 2: + suicide_dass_detected = True + break + + if open_safety_event or suicide_dass_detected: + is_crisis = True + dominant_condition = "crisis" + severity_level = "Krisis / Darurat" + condition_label = "Protokol Krisis & Bantuan Darurat" + condition_reason = ( + "Terdeteksi indikasi krisis emosional atau pemikiran menyakiti diri. " + "Sesuai pedoman klinis WHO (Knowledge K013), prioritas utama saat ini adalah keselamatan dan pendampingan darurat." + ) + else: + dominant_condition = "general" + severity_level = "Normal" + condition_label = "Fokus: Relaksasi & Perawatan Diri" + condition_reason = "Menjaga ritme emosi dan ketenangan pikiran harian dengan latihan mandiri sederhana." + + # Prioritas 1: Asesmen DASS-21 + if latest_dass and (latest_dass.stress_score > 0 or latest_dass.anxiety_score > 0 or latest_dass.depression_score > 0): + s_score = latest_dass.stress_score + a_score = latest_dass.anxiety_score + d_score = latest_dass.depression_score + + if a_score >= s_score and a_score >= d_score and a_score >= 8: + dominant_condition = "anxiety" + sev = latest_dass.anxiety_severity + if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: + severity_level = "Berat" + condition_label = "Kecemasan Berat: Grounding Somatis" + condition_reason = ( + "Terdeteksi kecemasan tinggi atau overthinking intens. " + "Teknik Grounding somatis menstabilkan respon panik (Knowledge K002)." + ) + elif sev in ["Moderate", "Sedang"]: + severity_level = "Sedang" + condition_label = "Kecemasan Sedang: Notice & Name" + condition_reason = ( + "Terdeteksi kecemasan sedang & overthinking. " + "Metode 'Notice and Name' terbukti membantu melepaskan diri dari pikiran menjebak (Knowledge K003)." + ) + else: + severity_level = "Ringan" + condition_label = "Kecemasan Ringan: Stabilisasi Napas" + condition_reason = ( + "Terindikasi ketegangan cemas ringan. " + "Latihan pernapasan lambat teratur membantu memperlambat respon fisiologis cemas (Knowledge K009)." + ) + + elif s_score >= d_score and s_score >= 15: + dominant_condition = "stress" + sev = latest_dass.stress_severity + if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: + severity_level = "Berat" + condition_label = "Stres Berat: Grounding Badai Emosi" + condition_reason = ( + "Terdeteksi badai emosional dan stres tingkat berat. " + "Teknik Grounding intensif membantu menstabilkan diri dari pikiran yang membebani (Knowledge K002)." + ) + elif sev in ["Moderate", "Sedang"]: + severity_level = "Sedang" + condition_label = "Stres Sedang: PMR & Solusi Bertahap" + condition_reason = ( + "Terdeteksi stres tingkat sedang. " + "Relaksasi otot progresif (PMR) dan metode Stop-Think-Go meredakan ketegangan (Knowledge K002 & K011)." + ) + else: + severity_level = "Ringan" + condition_label = "Stres Ringan: Pernapasan 4-7-8" + condition_reason = ( + "Terdeteksi beban stres ringan. " + "Latihan napas 4-7-8 dan jeda digital membantu menenangkan pikiran (Knowledge K001)." + ) + + elif d_score >= 10: + dominant_condition = "depression" + sev = latest_dass.depression_severity + if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: + severity_level = "Berat" + condition_label = "Suasana Hati Sangat Rendah: Langkah Mikro" + condition_reason = ( + "Terindikasi keputusasaan atau devaluasi diri berat. " + "Fokuslah pada 1 langkah terkecil dan bicarakan dengan orang terdekat (Knowledge K005 & K010)." + ) + elif sev in ["Moderate", "Sedang"]: + severity_level = "Sedang" + condition_label = "Depresi Sedang: Aktivasi Perilaku" + condition_reason = ( + "Terindikasi kelelahan emosional dan anhedonia. " + "Aktivasi perilaku metode tangga 'Changing My Actions' memutus siklus murung (Knowledge K010)." + ) + else: + severity_level = "Ringan" + condition_label = "Suasana Hati Rendah: Refleksi Syukur" + condition_reason = ( + "Terindikasi suasana hati murung ringan. " + "Melatih rasa syukur dan pengenalan emosi membantu mengimbangi bias negatif (Knowledge K005 & K008)." + ) + + # Prioritas 2: Jika DASS belum terisi, periksa Diary + elif today_diary and today_diary.mental_health_scores: + scores = today_diary.mental_health_scores + stress = float(scores.get("stress", 0.0)) + anxiety = float(scores.get("anxiety", 0.0)) + depression = float(scores.get("depression", 0.0)) + + max_score = max(stress, anxiety, depression) + if max_score >= 0.25: + if anxiety >= stress and anxiety >= depression: + dominant_condition = "anxiety" + severity_level = "Sedang" if anxiety >= 0.6 else "Ringan" + condition_label = f"Kecemasan {severity_level}: Relaksasi Napas" + condition_reason = "Terindikasi kecemasan dari dialog terakhir. Latihan pernapasan terbukti menenangkan saraf (Knowledge K009)." + elif stress >= depression: + dominant_condition = "stress" + severity_level = "Sedang" if stress >= 0.6 else "Ringan" + condition_label = f"Stres {severity_level}: Pelepasan Beban" + condition_reason = "Terindikasi beban stres dari dialog terakhir. Teknik relaksasi membantu mengembalikan ketenangan (Knowledge K001)." + else: + dominant_condition = "depression" + severity_level = "Sedang" if depression >= 0.6 else "Ringan" + condition_label = f"Pemulihan Energi {severity_level}: Aktivasi" + condition_reason = "Terindikasi kelelahan emosional. Aktivitas ringan di luar ruangan membantu membangkitkan suasana hati (Knowledge K010)." + + # Query Coping Activities + query_target = ( + select(CopingActivity) + .where( + CopingActivity.is_active.is_(True), + CopingActivity.target_condition == dominant_condition, + ) + .order_by(CopingActivity.created_at.asc()) + ) + res_target = await self.db.execute(query_target) + target_activities = res_target.scalars().all() + + query_general = ( + select(CopingActivity) + .where( + CopingActivity.is_active.is_(True), + CopingActivity.target_condition != dominant_condition, + ) + .order_by(CopingActivity.created_at.asc()) + ) + res_general = await self.db.execute(query_general) + general_activities = res_general.scalars().all() + + all_sorted_activities = list(target_activities) + list(general_activities) + + # Check user completions for today + completion_query = select(UserActivityCompletion.activity_id).where( + UserActivityCompletion.user_id == user_id, + UserActivityCompletion.completed_date == today_wib, + ) + res_comp = await self.db.execute(completion_query) + completed_ids = set(res_comp.scalars().all()) + + primary_activity: CopingActivity | None = None + for act in target_activities: + if act.id not in completed_ids: + primary_activity = act + break + if not primary_activity and target_activities: + primary_activity = target_activities[0] + if not primary_activity and all_sorted_activities: + primary_activity = all_sorted_activities[0] + + all_recommendations_mapped = [self._map_act(a, completed_ids) for a in all_sorted_activities] + + primary_mapped = ( + self._map_act(primary_activity, completed_ids) + if primary_activity + else CopingActivityResponse( + id="default-1", + title="Latihan Pernapasan 4-7-8", + category="Mindfulness", + targetCondition=dominant_condition, + duration="5 Menit", + difficulty="Pemula", + level="Pemula", + description="Teknik pernapasan sederhana untuk menenangkan sistem saraf.", + instructions=[ + "Tarik napas dalam 4 detik.", + "Tahan napas 7 detik.", + "Hembuskan napas perlahan 8 detik.", + ], + rationale="Mengaktifkan saraf parasimpatik untuk meredakan ketegangan tubuh.", + iconName="spa", + isCompleted=False, + ) + ) + + alternatives_mapped = [ + self._map_act(a, completed_ids) + for a in all_sorted_activities + if primary_activity and a.id != primary_activity.id + ] + + return TodayRecommendationResponse( + targetCondition=dominant_condition, + conditionLabel=condition_label, + conditionReason=condition_reason, + severityLevel=severity_level, + isCrisis=is_crisis, + crisisHotline=crisis_hotline, + crisisGuidance=crisis_guidance, + todayActivity=primary_mapped, + completedTodayCount=len(completed_ids), + alternatives=alternatives_mapped, + allRecommendations=all_recommendations_mapped, + ) + + async def get_recommendations( + self, user_id: uuid.UUID, condition: str | None = None + ) -> list[CopingActivityResponse]: + """Get all available recommendations filtered by condition.""" + today_wib = get_wib_today() + + query = select(CopingActivity).where(CopingActivity.is_active.is_(True)) + if condition: + query = query.where(CopingActivity.target_condition == condition.lower().strip()) + query = query.order_by(CopingActivity.created_at.asc()) + + res = await self.db.execute(query) + coping_items = res.scalars().all() + + comp_query = select(UserActivityCompletion.activity_id).where( + UserActivityCompletion.user_id == user_id, + UserActivityCompletion.completed_date == today_wib, + ) + res_comp = await self.db.execute(comp_query) + completed_ids = set(res_comp.scalars().all()) + + return [self._map_act(r, completed_ids) for r in coping_items] + + async def mark_completed( + self, user_id: uuid.UUID, recommendation_id: str + ) -> CompleteActivityResponse: + """Mark a recommendation or coping activity as completed for today.""" + try: + r_uuid = uuid.UUID(recommendation_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid recommendation ID") + + today_wib = get_wib_today() + + # 1. Check if recommendation_id matches CopingActivity + query_act = select(CopingActivity).where(CopingActivity.id == r_uuid) + res_act = await self.db.execute(query_act) + activity = res_act.scalar_one_or_none() + + if activity: + check_q = select(UserActivityCompletion).where( + UserActivityCompletion.user_id == user_id, + UserActivityCompletion.activity_id == activity.id, + UserActivityCompletion.completed_date == today_wib, + ) + res_check = await self.db.execute(check_q) + existing = res_check.scalar_one_or_none() + + if not existing: + comp = UserActivityCompletion( + user_id=user_id, + activity_id=activity.id, + completed_date=today_wib, + ) + self.db.add(comp) + await self.db.commit() + + return CompleteActivityResponse( + message="Activity marked as completed for today", + activityId=str(activity.id), + completedDate=str(today_wib), + isCompleted=True, + ) + + # 2. Check legacy RecommendationItem + query_legacy = select(RecommendationItem).where(RecommendationItem.id == r_uuid) + res_legacy = await self.db.execute(query_legacy) + legacy_item = res_legacy.scalar_one_or_none() + + if legacy_item: + legacy_item.is_completed = True + await self.db.commit() + return CompleteActivityResponse( + message="Legacy recommendation marked as completed", + activityId=str(legacy_item.id), + isCompleted=True, + ) + + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Activity or recommendation item not found") diff --git a/apps/backend/api/app/services/status/__init__.py b/apps/backend/api/app/services/status/__init__.py new file mode 100644 index 0000000..e27ac0e --- /dev/null +++ b/apps/backend/api/app/services/status/__init__.py @@ -0,0 +1,3 @@ +from app.services.status.status_service import SystemStatusService + +__all__ = ["SystemStatusService"] diff --git a/apps/backend/api/app/services/status_service.py b/apps/backend/api/app/services/status/status_service.py similarity index 100% rename from apps/backend/api/app/services/status_service.py rename to apps/backend/api/app/services/status/status_service.py diff --git a/apps/backend/api/app/services/user/__init__.py b/apps/backend/api/app/services/user/__init__.py new file mode 100644 index 0000000..9789bbe --- /dev/null +++ b/apps/backend/api/app/services/user/__init__.py @@ -0,0 +1,3 @@ +from app.services.user.user_service import UserService + +__all__ = ["UserService"] diff --git a/apps/backend/api/app/services/user/user_service.py b/apps/backend/api/app/services/user/user_service.py new file mode 100644 index 0000000..aa678d3 --- /dev/null +++ b/apps/backend/api/app/services/user/user_service.py @@ -0,0 +1,143 @@ +import logging +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import EmergencyContact, User +from app.schemas.user import ( + CreateEmergencyContactRequest, + DeleteContactResponse, + EmergencyContactResponse, + UpdateEmergencyContactRequest, +) + +logger = logging.getLogger(__name__) + + +class UserService: + """Service handling user profile and emergency contacts management.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_emergency_contacts(self, user_id: uuid.UUID) -> list[EmergencyContactResponse]: + """Fetch all emergency contacts registered for the specified user.""" + query = select(EmergencyContact).where(EmergencyContact.user_id == user_id) + res = await self.db.execute(query) + contacts = res.scalars().all() + + return [ + EmergencyContactResponse( + id=str(c.id), + name=c.name, + relationship=c.relationship, + phone=c.phone_number, + isPrimary=c.is_primary, + ) + for c in contacts + ] + + async def create_emergency_contact( + self, user_id: uuid.UUID, payload: CreateEmergencyContactRequest + ) -> EmergencyContactResponse: + """Create a new emergency contact, unsetting previous primary contact if requested.""" + if payload.is_primary: + existing_q = select(EmergencyContact).where( + EmergencyContact.user_id == user_id, + EmergencyContact.is_primary == True, # noqa: E712 + ) + existing_res = await self.db.execute(existing_q) + for c in existing_res.scalars().all(): + c.is_primary = False + + contact = EmergencyContact( + user_id=user_id, + name=payload.name, + relationship=payload.relationship, + phone_number=payload.phone_number, + is_primary=payload.is_primary, + ) + self.db.add(contact) + await self.db.commit() + await self.db.refresh(contact) + + return EmergencyContactResponse( + id=str(contact.id), + name=contact.name, + relationship=contact.relationship, + phone=contact.phone_number, + isPrimary=contact.is_primary, + ) + + async def update_emergency_contact( + self, user_id: uuid.UUID, contact_id: str, payload: UpdateEmergencyContactRequest + ) -> EmergencyContactResponse: + """Update existing emergency contact details with ownership verification.""" + try: + c_uuid = uuid.UUID(contact_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid contact ID") + + query = select(EmergencyContact).where( + EmergencyContact.id == c_uuid, + EmergencyContact.user_id == user_id, + ) + res = await self.db.execute(query) + contact = res.scalar_one_or_none() + + if not contact: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found") + + if payload.name is not None: + contact.name = payload.name + if payload.relationship is not None: + contact.relationship = payload.relationship + if payload.phone_number is not None: + contact.phone_number = payload.phone_number + if payload.is_primary is not None: + if payload.is_primary: + existing_q = select(EmergencyContact).where( + EmergencyContact.user_id == user_id, + EmergencyContact.is_primary == True, # noqa: E712 + ) + existing_res = await self.db.execute(existing_q) + for c in existing_res.scalars().all(): + c.is_primary = False + contact.is_primary = payload.is_primary + + await self.db.commit() + await self.db.refresh(contact) + + return EmergencyContactResponse( + id=str(contact.id), + name=contact.name, + relationship=contact.relationship, + phone=contact.phone_number, + isPrimary=contact.is_primary, + ) + + async def delete_emergency_contact( + self, user_id: uuid.UUID, contact_id: str + ) -> DeleteContactResponse: + """Remove an emergency contact if owned by the user.""" + try: + c_uuid = uuid.UUID(contact_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid contact ID") + + query = select(EmergencyContact).where( + EmergencyContact.id == c_uuid, + EmergencyContact.user_id == user_id, + ) + res = await self.db.execute(query) + contact = res.scalar_one_or_none() + + if not contact: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found") + + await self.db.delete(contact) + await self.db.commit() + + return DeleteContactResponse() diff --git a/apps/backend/api/tests/test_call_websocket.py b/apps/backend/api/tests/test_call_websocket.py index d4ab6f9..b744fb4 100644 --- a/apps/backend/api/tests/test_call_websocket.py +++ b/apps/backend/api/tests/test_call_websocket.py @@ -1,7 +1,7 @@ import json import pytest from app.main import app -from app.services.audio_stream_buffer import AudioStreamBufferService, analyze_audio_placeholder +from app.services.call import AudioStreamBufferService, analyze_audio_placeholder from fastapi.testclient import TestClient client = TestClient(app) @@ -66,3 +66,25 @@ def test_call_websocket_lifecycle(): # 7. Client sends end_call websocket.send_json({"type": "end_call"}) + + +def test_call_websocket_authenticated_mobile_flow(): + """Validates mobile WebSocket connection with JWT token and voice_mode query params.""" + login_res = client.post("/api/v1/auth/login", json={"email": "samsul@gmail.com", "password": "password123"}) + assert login_res.status_code == 200 + token = login_res.json()["access_token"] + + with client.websocket_connect(f"/api/v1/call/ws/test_mobile_auth_session?token={token}&voice_mode=supportive") as websocket: + connected = websocket.receive_json() + assert connected["type"] == "call_connected" + assert connected["session_id"] == "test_mobile_auth_session" + + websocket.send_json({"type": "start_call", "voice_mode": "supportive"}) + started = websocket.receive_json() + assert started["type"] == "call_started" + + websocket.send_json({"type": "ping"}) + pong = websocket.receive_json() + assert pong["type"] == "pong" + + websocket.send_json({"type": "end_call"}) diff --git a/apps/backend/api/tests/test_clean_architecture.py b/apps/backend/api/tests/test_clean_architecture.py new file mode 100644 index 0000000..6fb0883 --- /dev/null +++ b/apps/backend/api/tests/test_clean_architecture.py @@ -0,0 +1,121 @@ +"""Clean Architecture and Thin Router invariant tests. + +This test suite ensures that future modifications to the backend codebase never +violate our Clean Architecture standards: +1. No loose service/schema files in root directories. +2. Every domain module has an __init__.py barrel export. +3. Routers are purely thin HTTP transport controllers (<= 15 lines per handler). +4. Zero inline SQL or duplicated authentication logic in routers. +5. All REST endpoints declare Pydantic response_model (except StreamingResponse and WebSocket). +""" + +import ast +from pathlib import Path + +BACKEND_APP_DIR = Path(__file__).resolve().parent.parent / "app" + + +def test_no_loose_service_or_schema_files(): + """Ensure no loose .py files reside directly at app/services/ or app/schemas/ root.""" + services_dir = BACKEND_APP_DIR / "services" + schemas_dir = BACKEND_APP_DIR / "schemas" + + loose_services = [ + f.name for f in services_dir.glob("*.py") if f.name != "__init__.py" + ] + loose_schemas = [ + f.name for f in schemas_dir.glob("*.py") if f.name != "__init__.py" + ] + + assert not loose_services, ( + f"Found loose files in app/services/: {loose_services}. " + "Move them to app/services/{{module}}/ instead." + ) + assert not loose_schemas, ( + f"Found loose files in app/schemas/: {loose_schemas}. " + "Move them to app/schemas/{{module}}/ instead." + ) + + +def test_all_modules_have_barrel_export(): + """Ensure every domain subfolder in services and schemas has an __init__.py.""" + for parent_dir in ["services", "schemas"]: + target_dir = BACKEND_APP_DIR / parent_dir + for subfolder in target_dir.iterdir(): + if subfolder.is_dir() and not subfolder.name.startswith("__"): + init_file = subfolder / "__init__.py" + assert init_file.exists(), ( + f"Missing __init__.py barrel export in {subfolder}." + ) + + +def test_router_clean_architecture_invariants(): + """Ensure all route handlers in app/api/routes adhere strictly to Thin Controller standards.""" + routes_dir = BACKEND_APP_DIR / "api" / "routes" + + for route_file in routes_dir.glob("*.py"): + if route_file.name == "__init__.py": + continue + + content = route_file.read_text(encoding="utf-8") + + # 1. No private authentication helper + assert "_get_current_user" not in content, ( + f"Duplicated private '_get_current_user' found in {route_file.name}. " + "Import 'get_current_user' from app.api.dependencies instead." + ) + + # 2. No inline SQL query executions + assert "select(" not in content, ( + f"Inline SQLAlchemy select() query found in {route_file.name}. " + "Delegate database interactions to domain services." + ) + assert "db.execute(" not in content, ( + f"Inline db.execute() found in {route_file.name}. " + "Delegate database interactions to domain services." + ) + assert "db.commit(" not in content, ( + f"Inline db.commit() found in {route_file.name}. " + "Delegate database transactions to domain services." + ) + + # 3. AST Inspection: function length and response_model declaration + tree = ast.parse(content, filename=str(route_file)) + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + is_route = False + is_ws = False + has_response_model = False + + for dec in node.decorator_list: + if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute): + if dec.func.attr == "websocket": + is_ws = True + is_route = True + elif dec.func.attr in ["get", "post", "put", "patch", "delete"]: + is_route = True + for kw in dec.keywords: + if kw.arg == "response_model": + has_response_model = True + + if is_route: + # Check for StreamingResponse return type exception + is_streaming = False + if node.returns and isinstance(node.returns, ast.Name): + if node.returns.id == "StreamingResponse": + is_streaming = True + + if not is_ws and not is_streaming: + assert has_response_model, ( + f"Endpoint '{node.name}' in {route_file.name} missing 'response_model'. " + "Every REST endpoint must return a typed Pydantic response." + ) + + # Check function length (inclusive of body and docstring) + # Handlers must remain thin controllers (<= 18 lines) + func_lines = node.end_lineno - node.lineno + 1 + assert func_lines <= 18, ( + f"Endpoint '{node.name}' in {route_file.name} is {func_lines} lines long. " + "Route handlers must remain thin controllers (<= 15 lines). " + "Move logic to the domain service." + ) diff --git a/apps/backend/api/tests/test_dass_routes.py b/apps/backend/api/tests/test_dass_routes.py index 7baa5b9..23fcd64 100644 --- a/apps/backend/api/tests/test_dass_routes.py +++ b/apps/backend/api/tests/test_dass_routes.py @@ -3,7 +3,7 @@ from app.db.session import engine from app.main import app -from app.services.dass_service import DASSService +from app.services.dass import DASSService @pytest.fixture(autouse=True) diff --git a/apps/backend/api/tests/test_health.py b/apps/backend/api/tests/test_health.py index cc1eeb2..f877f1b 100644 --- a/apps/backend/api/tests/test_health.py +++ b/apps/backend/api/tests/test_health.py @@ -8,3 +8,14 @@ def test_root_endpoint(): response = client.get("/") assert response.status_code == 200 assert "Eva AI API Server" in response.json()["message"] + + +def test_health_endpoint(): + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["app"] == "api" + assert "database" in data + assert "redis" in data + assert "qdrant" in data diff --git a/apps/backend/api/tests/test_ml_emotion_detector.py b/apps/backend/api/tests/test_ml_emotion_detector.py index fed1224..d9d8798 100644 --- a/apps/backend/api/tests/test_ml_emotion_detector.py +++ b/apps/backend/api/tests/test_ml_emotion_detector.py @@ -1,7 +1,7 @@ import os from pathlib import Path import pytest -from app.services.ml_emotion_detector import MLEmotionDetectorService +from app.services.emotion import MLEmotionDetectorService # Paths to sample WAV audio files PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent.parent diff --git a/apps/backend/api/tests/test_turn_aggregator.py b/apps/backend/api/tests/test_turn_aggregator.py index dc7aad1..e9fde18 100644 --- a/apps/backend/api/tests/test_turn_aggregator.py +++ b/apps/backend/api/tests/test_turn_aggregator.py @@ -2,8 +2,7 @@ import pytest from app.core.call_config import CallTurnConfig -from app.services.turn_aggregator import TurnAggregator, merge_overlapping_segments -from app.services.turn_detection_service import TurnDetectionService +from app.services.call import TurnAggregator, TurnDetectionService, merge_overlapping_segments def test_merge_overlapping_segments(): diff --git a/apps/eva_mobile/AGENTS.md b/apps/eva_mobile/AGENTS.md index 719fb4f..eefaff7 100644 --- a/apps/eva_mobile/AGENTS.md +++ b/apps/eva_mobile/AGENTS.md @@ -32,3 +32,13 @@ - Multi-step interactive modals or bottom sheets. - Reusable loaders (skeleton shimmer) and buttons. - Place extracted widgets into `features/<feature>/presentation/widgets/`. + +## 5. Component Reuse & Design System Invariant +- **Reuse Existing Components First**: Before building custom UI elements or writing inline styling, ALWAYS check and reuse standard widgets in `lib/widgets/`: + - `AppBackButton`: Standardized frosted circular back button across all screens. + - `GlassCard`: Frosted glass container with rounded corners and gradient borders. + - `AppSnackBar`: Standardized snackbar notification banners. + - `SkeletonShimmer`: Standardized loading placeholder animations. + - `CustomButton` & `CustomTextField`: Standardized input elements. + - `StaggeredEntrance`: Fluid entrance animation wrapper. +- **No Inline Duplication**: Do not declare manual inline back buttons (`IconButton`, custom `BoxShape.circle`, etc.) directly inside screens. Always consume `AppBackButton`. diff --git a/apps/eva_mobile/lib/config/app_config.dart b/apps/eva_mobile/lib/config/app_config.dart index eaf9a44..e51c6db 100644 --- a/apps/eva_mobile/lib/config/app_config.dart +++ b/apps/eva_mobile/lib/config/app_config.dart @@ -3,7 +3,20 @@ import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; class AppConfig { - static bool useMockData = false; + /// Compile-time mock flag: --dart-define=USE_MOCK_DATA=true or --dart-define=MOCK=true + static bool useMockData = const bool.fromEnvironment( + 'USE_MOCK_DATA', + defaultValue: bool.fromEnvironment('MOCK', defaultValue: false), + ); + + /// Compile-time hybrid fallback flag: --dart-define=USE_HYBRID_FALLBACK=true or --dart-define=HYBRID=true + static const bool useHybridFallback = bool.fromEnvironment( + 'USE_HYBRID_FALLBACK', + defaultValue: bool.fromEnvironment('HYBRID', defaultValue: false), + ); + + /// Fast failover timeout for hybrid fallback requests to keep UI responsive + static const Duration hybridTimeout = Duration(seconds: 3); static String? authToken; static String? _cachedRefreshToken; static String? _customHost; @@ -11,10 +24,13 @@ class AppConfig { static String? _cachedUserName; static String? _cachedUserEmail; - /// Compile-time environment flag: --dart-define=USE_LOCAL_API=true or --dart-define=LOCAL=true + /// Compile-time environment flag: --dart-define=USE_LOCAL_API=true or --dart-define=LOCAL=true or --dart-define=USE_LOCALHOST=true static const bool useLocalApi = bool.fromEnvironment( 'USE_LOCAL_API', - defaultValue: bool.fromEnvironment('LOCAL', defaultValue: false), + defaultValue: bool.fromEnvironment( + 'LOCAL', + defaultValue: bool.fromEnvironment('USE_LOCALHOST', defaultValue: false), + ), ); /// Compile-time emulator flag: --dart-define=IS_EMULATOR=true or --dart-define=USE_EMULATOR=true @@ -27,6 +43,12 @@ class AppConfig { ), ); + /// Optional compile-time custom API base URL: --dart-define=API_BASE_URL=http://10.0.2.2:8888/api/v1 + static const String customBaseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: String.fromEnvironment('BASE_URL', defaultValue: ''), + ); + /// Optional compile-time custom host: --dart-define=LOCAL_HOST=192.168.1.100:8888 static const String customLocalHost = String.fromEnvironment( 'LOCAL_HOST', @@ -42,12 +64,22 @@ class AppConfig { /// Android emulator host (10.0.2.2:8888) static const String emulatorServerHost = '10.0.2.2:8888'; + /// Default seeded demo credentials for quick access and testing + static const String demoEmail = 'samsul@gmail.com'; + static const String demoPassword = 'password123'; + /// Get the active host (IP or domain). /// Respects --dart-define=USE_LOCAL_API=true when running locally. static String get host { if (_customHost != null && _customHost!.isNotEmpty) { return _customHost!; } + if (customBaseUrl.isNotEmpty) { + final uri = Uri.tryParse(customBaseUrl); + if (uri != null && uri.host.isNotEmpty) { + return uri.hasPort ? '${uri.host}:${uri.port}' : uri.host; + } + } if (customLocalHost.isNotEmpty) { return customLocalHost; } @@ -68,6 +100,7 @@ class AppConfig { /// Production domain uses HTTPS/WSS, while local dev loopbacks use HTTP/WS. static bool get isSecure { if (useLocalApi) return false; + if (customBaseUrl.startsWith('http://')) return false; final currentHost = host; if (currentHost.startsWith('127.') || currentHost.startsWith('10.') || @@ -82,6 +115,9 @@ class AppConfig { static String get wsScheme => isSecure ? 'wss' : 'ws'; static String get baseUrl { + if (customBaseUrl.isNotEmpty) { + return customBaseUrl; + } if (host.startsWith('http://') || host.startsWith('https://')) { return '$host/api/v1'; } @@ -200,6 +236,39 @@ class AppConfig { await prefs.setString('user_email', email); } + /// Get cached user phone number + static Future<String?> getUserPhone() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('user_phone') ?? '+62 812-3456-7890'; + } + + /// Get cached user gender + static Future<String?> getUserGender() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('user_gender') ?? 'Laki-laki'; + } + + /// Get cached user birth date + static Future<String?> getUserBirthDate() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('user_birth_date') ?? '18 Agustus 2002'; + } + + /// Save detailed user profile data + static Future<void> setUserProfileDetails({ + required String name, + required String email, + String? phone, + String? gender, + String? birthDate, + }) async { + await setUserInfo(name: name, email: email); + final prefs = await SharedPreferences.getInstance(); + if (phone != null) await prefs.setString('user_phone', phone); + if (gender != null) await prefs.setString('user_gender', gender); + if (birthDate != null) await prefs.setString('user_birth_date', birthDate); + } + /// Clear token and profile info (Logout) static Future<void> clearToken() async { authToken = null; @@ -213,6 +282,18 @@ class AppConfig { await prefs.remove('user_email'); } + /// Get cached coin balance + static Future<int> getCoinBalance() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('user_coin_balance') ?? 120; + } + + /// Set cached coin balance + static Future<void> setCoinBalance(int coins) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('user_coin_balance', coins); + } + /// Synchronous default headers static Map<String, String> get defaultHeaders => { 'Content-Type': 'application/json', diff --git a/apps/eva_mobile/lib/core/network/api_client.dart b/apps/eva_mobile/lib/core/network/api_client.dart index eab0806..1440b65 100644 --- a/apps/eva_mobile/lib/core/network/api_client.dart +++ b/apps/eva_mobile/lib/core/network/api_client.dart @@ -1,6 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; /// Custom Exception for API Errors class ApiException implements Exception { @@ -19,12 +19,15 @@ class ApiClient { ApiClient({http.Client? client}) : _client = client ?? http.Client(); + Duration get _effectiveTimeout => + AppConfig.useHybridFallback ? AppConfig.hybridTimeout : const Duration(seconds: 10); + Future<dynamic> get(String path, {Map<String, String>? queryParams}) async { try { final cleanPath = path.startsWith('/') ? path : '/$path'; final uri = Uri.parse('${AppConfig.baseUrl}$cleanPath').replace(queryParameters: queryParams); final headers = await AppConfig.getAuthHeaders(); - final response = await _client.get(uri, headers: headers); + final response = await _client.get(uri, headers: headers).timeout(_effectiveTimeout); return _processResponse(response); } catch (e) { if (e is ApiException) rethrow; @@ -41,7 +44,7 @@ class ApiClient { uri, headers: headers, body: body != null ? jsonEncode(body) : null, - ); + ).timeout(_effectiveTimeout); return _processResponse(response); } catch (e) { if (e is ApiException) rethrow; @@ -58,7 +61,7 @@ class ApiClient { uri, headers: headers, body: body != null ? jsonEncode(body) : null, - ); + ).timeout(_effectiveTimeout); return _processResponse(response); } catch (e) { if (e is ApiException) rethrow; @@ -71,7 +74,7 @@ class ApiClient { final cleanPath = path.startsWith('/') ? path : '/$path'; final uri = Uri.parse('${AppConfig.baseUrl}$cleanPath'); final headers = await AppConfig.getAuthHeaders(); - final response = await _client.delete(uri, headers: headers); + final response = await _client.delete(uri, headers: headers).timeout(_effectiveTimeout); return _processResponse(response); } catch (e) { if (e is ApiException) rethrow; diff --git a/apps/eva_mobile/lib/core/network/fallback_policy.dart b/apps/eva_mobile/lib/core/network/fallback_policy.dart new file mode 100644 index 0000000..e152bcb --- /dev/null +++ b/apps/eva_mobile/lib/core/network/fallback_policy.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'api_client.dart'; + +/// Policy governing whether a failed remote operation should gracefully fallback to mock data. +class FallbackPolicy { + /// Evaluates whether an error indicates an unavailable backend or missing endpoint, + /// qualifying it for a seamless fallback to Mock DataSource. + /// + /// Criteria: + /// - TRUE for: SocketException, TimeoutException, connection refused, host lookup failures, + /// or HTTP 404 (Not Found) / 501 (Not Implemented). + /// - FALSE for: HTTP 401 (Unauthorized), 403 (Forbidden), 422 (Unprocessable), + /// or 500 (Internal Server Error) to avoid masking actual business bugs. + static bool shouldFallback(Object error) { + if (error is SocketException || error is TimeoutException) { + return true; + } + if (error is ApiException) { + final code = error.statusCode; + if (code == 404 || code == 501 || code == null) { + return true; + } + return false; // Real errors (401, 403, 422, 500) must propagate + } + final msg = error.toString().toLowerCase(); + if (msg.contains('connection refused') || + msg.contains('failed host lookup') || + msg.contains('network is unreachable') || + msg.contains('failed to connect') || + msg.contains('timed out')) { + return true; + } + return false; + } +} + +/// Helper utility for coordinating remote execution with transparent fallback to mock data. +class FallbackExecutor { + /// Executes [remoteCall]. If an error occurs that satisfies [FallbackPolicy.shouldFallback], + /// logs a prominent warning and returns the result of [mockCall]. + /// Otherwise, rethrows the original error. + static Future<T> execute<T>({ + required Future<T> Function() remoteCall, + required Future<T> Function() mockCall, + required String operationName, + }) async { + try { + return await remoteCall(); + } catch (e) { + if (FallbackPolicy.shouldFallback(e)) { + debugPrint('⚠️ [HYBRID-FALLBACK] $operationName gagal ($e). Beralih ke Mock DataSource.'); + return await mockCall(); + } + rethrow; + } + } + + /// Executes a streaming [remoteStreamCall]. If it fails with a fallback-eligible error, + /// seamlessly yields events from [mockStreamCall]. + static Stream<T> executeStream<T>({ + required Stream<T> Function() remoteStreamCall, + required Stream<T> Function() mockStreamCall, + required String operationName, + }) async* { + bool hasYielded = false; + try { + await for (final event in remoteStreamCall()) { + hasYielded = true; + yield event; + } + } catch (e) { + if (!hasYielded && FallbackPolicy.shouldFallback(e)) { + debugPrint('⚠️ [HYBRID-FALLBACK] Stream $operationName gagal ($e). Beralih ke Mock Stream.'); + await for (final event in mockStreamCall()) { + yield event; + } + } else { + rethrow; + } + } + } +} diff --git a/apps/eva_mobile/lib/core/routes/app_router.dart b/apps/eva_mobile/lib/core/routes/app_router.dart index d3c34e6..81eee65 100644 --- a/apps/eva_mobile/lib/core/routes/app_router.dart +++ b/apps/eva_mobile/lib/core/routes/app_router.dart @@ -1,22 +1,31 @@ import 'package:flutter/material.dart'; -import '../../screens/ai_conversation_screen.dart'; -import '../../screens/ai_diary_detail_screen.dart'; -import '../../screens/ai_diary_screen.dart'; -import '../../screens/dass_assessment_screen.dart'; -import '../../screens/emergency_contacts_screen.dart'; -import '../../screens/login_screen.dart'; -import '../../screens/main_shell_screen.dart'; -import '../../screens/monitoring_screen.dart'; -import '../../screens/profile_screen.dart'; -import '../../screens/recommendation_screen.dart'; -import '../../screens/register_screen.dart'; -import '../../screens/settings_screen.dart'; -import '../../screens/splash_onboarding_screen.dart'; -import '../../screens/support_emergency_screen.dart'; -import '../../screens/terms_disclaimer_screen.dart'; -import '../../screens/voice_call_screen.dart'; -import '../../screens/voice_session_detail_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/login_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/register_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/profile_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/settings_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/terms_disclaimer_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/splash_onboarding_screen.dart'; +import 'package:eva_mobile/features/chat/presentation/screens/ai_conversation_screen.dart'; +import 'package:eva_mobile/features/dass/presentation/screens/dass_assessment_screen.dart'; +import 'package:eva_mobile/features/diary/presentation/screens/ai_diary_screen.dart'; +import 'package:eva_mobile/features/diary/presentation/screens/ai_diary_detail_screen.dart'; +import 'package:eva_mobile/features/emergency/presentation/screens/emergency_contacts_screen.dart'; +import 'package:eva_mobile/features/emergency/presentation/screens/support_emergency_screen.dart'; +import 'package:eva_mobile/features/care/domain/entities/psychologist_entity.dart'; +import 'package:eva_mobile/features/care/domain/entities/consultation_session_entity.dart'; +import 'package:eva_mobile/features/care/presentation/screens/psychologist_detail_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_schedule_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_history_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_session_logs_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/psychologist_video_call_screen.dart'; +import 'package:eva_mobile/features/coin/presentation/screens/coin_purchase_screen.dart'; +import 'package:eva_mobile/features/home/presentation/screens/main_shell_screen.dart'; +import 'package:eva_mobile/features/monitoring/presentation/screens/monitoring_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/care_screen.dart'; +import 'package:eva_mobile/features/recommendations/presentation/screens/recommendation_screen.dart'; +import 'package:eva_mobile/features/voice_call/presentation/screens/voice_call_screen.dart'; +import 'package:eva_mobile/features/voice_call/presentation/screens/voice_session_detail_screen.dart'; import 'app_route_transitions.dart'; /// Centralized route generator applying custom transitions to all Eva Mobile pages. @@ -58,8 +67,14 @@ class AppRouter { page: const MonitoringScreen(), settings: settings, ); - case '/profile': + case '/care': return AppRouteTransitions.fadeThroughRoute( + page: const CareScreen(), + settings: settings, + ); + case '/user_profile': + case '/profile': + return AppRouteTransitions.horizontalDetailRoute( page: const ProfileScreen(), settings: settings, ); @@ -121,6 +136,65 @@ class AppRouter { page: const TermsDisclaimerScreen(), settings: settings, ); + case '/coin_purchase': + case '/coins': + case '/buy_coins': + return AppRouteTransitions.horizontalDetailRoute( + page: const CoinPurchaseScreen(), + settings: settings, + ); + case '/psychologist_detail': + case '/care/psychologist_detail': + final psychologist = args?['psychologist'] as PsychologistEntity?; + if (psychologist != null) { + return AppRouteTransitions.horizontalDetailRoute( + page: PsychologistDetailScreen(psychologist: psychologist), + settings: settings, + ); + } + return null; + + case '/care/schedule': + case '/consultation_schedule': + case '/schedule': + return AppRouteTransitions.horizontalDetailRoute( + page: const ConsultationScheduleScreen(), + settings: settings, + ); + + case '/care/history': + case '/consultation_history': + case '/history': + return AppRouteTransitions.horizontalDetailRoute( + page: const ConsultationHistoryScreen(), + settings: settings, + ); + + case '/care/session_logs': + case '/consultation_session_logs': + final psychologist = args?['psychologist'] as PsychologistEntity?; + final sessions = args?['sessions'] as List<ConsultationSessionEntity>? ?? []; + if (psychologist != null) { + return AppRouteTransitions.horizontalDetailRoute( + page: ConsultationSessionLogsScreen( + psychologist: psychologist, + sessions: sessions, + ), + settings: settings, + ); + } + return null; + + case '/care/video_call': + case '/psychologist_video_call': + final session = args?['session'] as ConsultationSessionEntity?; + if (session != null) { + return AppRouteTransitions.horizontalDetailRoute( + page: PsychologistVideoCallScreen(session: session), + settings: settings, + ); + } + return null; default: return null; diff --git a/apps/eva_mobile/lib/features/auth/data/datasources/auth_datasource.dart b/apps/eva_mobile/lib/features/auth/data/datasources/auth_datasource.dart index 00494cf..d0687e5 100644 --- a/apps/eva_mobile/lib/features/auth/data/datasources/auth_datasource.dart +++ b/apps/eva_mobile/lib/features/auth/data/datasources/auth_datasource.dart @@ -5,6 +5,7 @@ abstract class AuthDataSource { Future<UserModel?> getCurrentUser(); Future<UserModel> login(String email, String password); Future<UserModel> register(String name, String email, String password); + Future<bool> checkEmailAvailability(String email); Future<void> logout(); Future<List<EmergencyContactModel>> getEmergencyContacts(); Future<EmergencyContactModel> addEmergencyContact(EmergencyContactModel contact); diff --git a/apps/eva_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart b/apps/eva_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart index 2d431d7..3bb094d 100644 --- a/apps/eva_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart +++ b/apps/eva_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart @@ -9,7 +9,7 @@ class MockAuthDataSource implements AuthDataSource { name: 'Samsul', email: 'samsul@gmail.com', phone: '+62 812-3456-7890', - bio: 'Pengguna aktif EVA AI.', + bio: 'Pengguna aktif Eva AI.', ); final List<EmergencyContactModel> _contacts = [ @@ -67,6 +67,12 @@ class MockAuthDataSource implements AuthDataSource { return _currentUser!; } + @override + Future<bool> checkEmailAvailability(String email) async { + await Future.delayed(const Duration(milliseconds: 200)); + return !email.contains('taken'); + } + @override Future<void> logout() async { await Future.delayed(const Duration(milliseconds: 300)); diff --git a/apps/eva_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart b/apps/eva_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart index e41031d..be24701 100644 --- a/apps/eva_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart +++ b/apps/eva_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart @@ -2,7 +2,7 @@ import 'auth_datasource.dart'; import '../models/user_model.dart'; import '../models/emergency_contact_model.dart'; import '../../../../core/network/api_client.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../../../core/storage/token_storage.dart'; class RemoteAuthDataSource implements AuthDataSource { @@ -25,10 +25,19 @@ class RemoteAuthDataSource implements AuthDataSource { }); if (response['access_token'] != null) { final token = response['access_token'] as String; + final refreshToken = response['refresh_token'] as String?; AppConfig.authToken = token; + if (refreshToken != null) { + await AppConfig.setTokens(accessToken: token, refreshToken: refreshToken); + } else { + await AppConfig.setToken(token); + } await TokenStorage.saveToken(token); } - return UserModel.fromJson(response['user']); + final userData = response['user'] as Map<String, dynamic>? ?? {}; + final user = UserModel.fromJson(userData); + await AppConfig.setUserInfo(name: user.name, email: user.email); + return user; } @override @@ -40,10 +49,32 @@ class RemoteAuthDataSource implements AuthDataSource { }); if (response['access_token'] != null) { final token = response['access_token'] as String; + final refreshToken = response['refresh_token'] as String?; AppConfig.authToken = token; + if (refreshToken != null) { + await AppConfig.setTokens(accessToken: token, refreshToken: refreshToken); + } else { + await AppConfig.setToken(token); + } await TokenStorage.saveToken(token); } - return UserModel.fromJson(response['user']); + final userData = response['user'] as Map<String, dynamic>? ?? {}; + final user = UserModel.fromJson(userData); + await AppConfig.setUserInfo(name: user.name, email: user.email); + return user; + } + + @override + Future<bool> checkEmailAvailability(String email) async { + try { + final response = await _apiClient.get('/auth/check-email', queryParams: {'email': email}); + if (response is Map && response['available'] != null) { + return response['available'] == true; + } + return true; + } catch (_) { + return true; + } } @override diff --git a/apps/eva_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart b/apps/eva_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart index f41abfa..4a36f4b 100644 --- a/apps/eva_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/apps/eva_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -18,6 +18,9 @@ class AuthRepositoryImpl implements AuthRepository { @override Future<UserEntity> register(String name, String email, String password) => _dataSource.register(name, email, password); + @override + Future<bool> checkEmailAvailability(String email) => _dataSource.checkEmailAvailability(email); + @override Future<void> logout() => _dataSource.logout(); diff --git a/apps/eva_mobile/lib/features/auth/data/repositories/fallback_auth_repository.dart b/apps/eva_mobile/lib/features/auth/data/repositories/fallback_auth_repository.dart new file mode 100644 index 0000000..ca30afa --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/repositories/fallback_auth_repository.dart @@ -0,0 +1,87 @@ +import '../../domain/repositories/auth_repository.dart'; +import '../../domain/entities/user_entity.dart'; +import '../../domain/entities/emergency_contact_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackAuthRepository implements AuthRepository { + final AuthRepository _remote; + final AuthRepository _mock; + + FallbackAuthRepository({ + required AuthRepository remote, + required AuthRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future<UserEntity?> getCurrentUser() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getCurrentUser(), + mockCall: () => _mock.getCurrentUser(), + operationName: 'getCurrentUser', + ); + } + + @override + Future<UserEntity> login(String email, String password) { + return FallbackExecutor.execute( + remoteCall: () => _remote.login(email, password), + mockCall: () => _mock.login(email, password), + operationName: 'login', + ); + } + + @override + Future<UserEntity> register(String name, String email, String password) { + return FallbackExecutor.execute( + remoteCall: () => _remote.register(name, email, password), + mockCall: () => _mock.register(name, email, password), + operationName: 'register', + ); + } + + @override + Future<bool> checkEmailAvailability(String email) { + return FallbackExecutor.execute( + remoteCall: () => _remote.checkEmailAvailability(email), + mockCall: () => _mock.checkEmailAvailability(email), + operationName: 'checkEmailAvailability', + ); + } + + @override + Future<void> logout() { + return FallbackExecutor.execute( + remoteCall: () => _remote.logout(), + mockCall: () => _mock.logout(), + operationName: 'logout', + ); + } + + @override + Future<List<EmergencyContactEntity>> getEmergencyContacts() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getEmergencyContacts(), + mockCall: () => _mock.getEmergencyContacts(), + operationName: 'getEmergencyContacts', + ); + } + + @override + Future<EmergencyContactEntity> addEmergencyContact(EmergencyContactEntity contact) { + return FallbackExecutor.execute( + remoteCall: () => _remote.addEmergencyContact(contact), + mockCall: () => _mock.addEmergencyContact(contact), + operationName: 'addEmergencyContact', + ); + } + + @override + Future<void> deleteEmergencyContact(String id) { + return FallbackExecutor.execute( + remoteCall: () => _remote.deleteEmergencyContact(id), + mockCall: () => _mock.deleteEmergencyContact(id), + operationName: 'deleteEmergencyContact', + ); + } +} diff --git a/apps/eva_mobile/lib/features/auth/domain/repositories/auth_repository.dart b/apps/eva_mobile/lib/features/auth/domain/repositories/auth_repository.dart index 915a9a6..00b5987 100644 --- a/apps/eva_mobile/lib/features/auth/domain/repositories/auth_repository.dart +++ b/apps/eva_mobile/lib/features/auth/domain/repositories/auth_repository.dart @@ -5,6 +5,7 @@ abstract class AuthRepository { Future<UserEntity?> getCurrentUser(); Future<UserEntity> login(String email, String password); Future<UserEntity> register(String name, String email, String password); + Future<bool> checkEmailAvailability(String email); Future<void> logout(); Future<List<EmergencyContactEntity>> getEmergencyContacts(); Future<EmergencyContactEntity> addEmergencyContact(EmergencyContactEntity contact); diff --git a/apps/eva_mobile/lib/features/auth/presentation/providers/auth_provider.dart b/apps/eva_mobile/lib/features/auth/presentation/providers/auth_provider.dart index c9777d3..306f7f9 100644 --- a/apps/eva_mobile/lib/features/auth/presentation/providers/auth_provider.dart +++ b/apps/eva_mobile/lib/features/auth/presentation/providers/auth_provider.dart @@ -1,17 +1,23 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/auth_repository.dart'; import '../../domain/entities/user_entity.dart'; import '../../domain/entities/emergency_contact_entity.dart'; import '../../data/datasources/mock_auth_datasource.dart'; import '../../data/datasources/remote_auth_datasource.dart'; import '../../data/repositories/auth_repository_impl.dart'; +import '../../data/repositories/fallback_auth_repository.dart'; final authRepositoryProvider = Provider<AuthRepository>((ref) { + final mockRepo = AuthRepositoryImpl(MockAuthDataSource()); + final remoteRepo = AuthRepositoryImpl(RemoteAuthDataSource()); + if (AppConfig.useMockData) { - return AuthRepositoryImpl(MockAuthDataSource()); + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackAuthRepository(remote: remoteRepo, mock: mockRepo); } else { - return AuthRepositoryImpl(RemoteAuthDataSource()); + return remoteRepo; } }); diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/login_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/login_screen.dart new file mode 100644 index 0000000..cdc344d --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/login_screen.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/custom_text_field.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/social_auth_dev_sheet.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState<LoginScreen> createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState<LoginScreen> { + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + void _fillDemoAccount() { + setState(() { + _emailController.text = AppConfig.demoEmail; + _passwordController.text = AppConfig.demoPassword; + }); + AppSnackBar.showPrimary( + context, + 'Akun demo berhasil diisi (${AppConfig.demoEmail})', + icon: Icons.check_circle_outline, + duration: const Duration(seconds: 2), + ); + } + + Future<void> _handleLogin() async { + final email = _emailController.text.trim().toLowerCase(); + final password = _passwordController.text.trim(); + + if (email.isEmpty || password.isEmpty) { + AppSnackBar.showWarning(context, 'Silakan masukkan email dan kata sandi'); + return; + } + + setState(() => _isLoading = true); + + try { + final authRepo = ref.read(authRepositoryProvider); + final user = await authRepo.login(email, password); + + if (!mounted) return; + AppSnackBar.showPrimary( + context, + 'Selamat datang kembali, ${user.name}!', + icon: Icons.check_circle_outline, + duration: const Duration(seconds: 2), + ); + Navigator.pushReplacementNamed(context, '/home'); + } catch (e) { + if (!mounted) return; + AppSnackBar.showError( + context, + 'Gagal masuk: ${e.toString().replaceAll('ApiException: ', '')}', + duration: const Duration(seconds: 4), + ); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF3F5FF), + Color(0xFFE9ECFF), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Brand Logo + Image.asset( + 'assets/images/eva_logo.png', + width: 50, + height: 50, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.nightlight_round, + size: 44, + color: AppColors.primary, + ); + }, + ), + const SizedBox(height: 8), + + Text( + 'Selamat Datang Kembali', + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + + Text( + 'Masuk untuk melanjutkan perjalanan ketenanganmu', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 28), + + // Email Field + CustomPillTextField( + controller: _emailController, + hintText: 'Alamat Email', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 14), + + // Password Field + CustomPillTextField( + controller: _passwordController, + hintText: 'Kata Sandi', + prefixIcon: Icons.lock_outline, + isPassword: true, + ), + const SizedBox(height: 10), + + // Demo Account Autofill Button + Align( + alignment: Alignment.centerRight, + child: Material( + color: Colors.transparent, + child: InkWell( + key: const Key('login_demo_autofill_button'), + borderRadius: BorderRadius.circular(6), + onTap: _fillDemoAccount, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.auto_awesome_rounded, + size: 14, + color: AppColors.primary, + ), + const SizedBox(width: 4), + Text( + 'Gunakan Akun Demo', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 24), + + // Login Button + _isLoading + ? const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ) + : CustomPillButton( + text: 'Masuk', + onPressed: _handleLogin, + ), + const SizedBox(height: 20), + + // Or Divider + const Row( + children: [ + Expanded(child: Divider(color: Color(0xFFD6DCF5))), + Padding( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + 'atau masuk dengan', + style: TextStyle( + fontSize: 12, + color: AppColors.textLight, + ), + ), + ), + Expanded(child: Divider(color: Color(0xFFD6DCF5))), + ], + ), + const SizedBox(height: 20), + + // Social Login Buttons Row + Row( + children: [ + Expanded( + child: SocialPillButton( + type: 'google', + onPressed: () { + showSocialAuthDevSheet(context, provider: 'Google'); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SocialPillButton( + type: 'apple', + onPressed: () { + showSocialAuthDevSheet(context, provider: 'Apple'); + }, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Register Link Footer + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Belum punya akun? ', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + GestureDetector( + onTap: () { + Navigator.pushReplacementNamed(context, '/register'); + }, + child: Text( + 'Daftar', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), +); + } +} diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/profile_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/profile_screen.dart new file mode 100644 index 0000000..0d31db1 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/profile_screen.dart @@ -0,0 +1,1093 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/features/coin/presentation/providers/coin_provider.dart'; +import '../providers/auth_provider.dart'; + +class ProfileScreen extends ConsumerStatefulWidget { + const ProfileScreen({super.key}); + + @override + ConsumerState<ProfileScreen> createState() => ProfileScreenState(); +} + +class ProfileScreenState extends ConsumerState<ProfileScreen> { + String _userName = 'Pengguna EVA'; + String _userEmail = ''; + String _userPhone = '+62 812-3456-7890'; + String _userGender = 'Laki-laki'; + String _userBirthDate = '18 Agustus 2002'; + final String _userLocation = 'Jakarta, Indonesia'; + String? _emergencyContactName; + String? _emergencyContactPhone; + + void refresh() { + _loadProfileData(); + } + + String get _userInitials { + final parts = _userName.trim().split(RegExp(r'\s+')); + if (parts.length >= 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } else if (parts.isNotEmpty && parts[0].isNotEmpty) { + return parts[0].substring(0, parts[0].length >= 2 ? 2 : 1).toUpperCase(); + } + return 'LU'; + } + + @override + void initState() { + super.initState(); + _loadProfileData(); + } + + Future<void> _loadProfileData() async { + // 1. Cached profile info + final cachedName = await AppConfig.getUserName(); + final cachedEmail = await AppConfig.getUserEmail(); + final cachedPhone = await AppConfig.getUserPhone(); + final cachedGender = await AppConfig.getUserGender(); + final cachedBirthDate = await AppConfig.getUserBirthDate(); + + if (mounted) { + setState(() { + if (cachedName != null && cachedName.isNotEmpty) { + _userName = cachedName; + } + if (cachedEmail != null && cachedEmail.isNotEmpty) { + _userEmail = cachedEmail; + } else { + _userEmail = AppConfig.demoEmail; + } + if (cachedPhone != null && cachedPhone.isNotEmpty) { + _userPhone = cachedPhone; + } + if (cachedGender != null && cachedGender.isNotEmpty) { + _userGender = cachedGender; + } + if (cachedBirthDate != null && cachedBirthDate.isNotEmpty) { + _userBirthDate = cachedBirthDate; + } + }); + } + + // 2. Fetch /auth/me via AuthRepository + try { + final user = await ref.read(authRepositoryProvider).getCurrentUser(); + if (user != null && mounted) { + setState(() { + _userName = user.name; + _userEmail = user.email; + }); + await AppConfig.setUserInfo(name: user.name, email: user.email); + } + } catch (_) {} + + // 3. Fetch primary emergency contact via AuthRepository + try { + final contacts = await ref.read(authRepositoryProvider).getEmergencyContacts(); + if (mounted) { + if (contacts.isNotEmpty) { + final primary = contacts.firstWhere( + (c) => c.isPrimary, + orElse: () => contacts.first, + ); + setState(() { + final rel = primary.relationship.isNotEmpty ? ' (${primary.relationship})' : ''; + _emergencyContactName = '${primary.name}$rel'; + _emergencyContactPhone = primary.phone; + }); + } else { + setState(() { + _emergencyContactName = null; + _emergencyContactPhone = null; + }); + } + } + } catch (_) {} + } + + void _showEditProfileModal() { + final nameCtrl = TextEditingController(text: _userName); + final phoneCtrl = TextEditingController(text: _userPhone); + String selectedGender = _userGender; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (modalCtx) { + return StatefulBuilder( + builder: (ctx, setModalState) { + return Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, + top: 20, + left: 20, + right: 20, + ), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Edit Profil Pengguna', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, size: 20), + onPressed: () => Navigator.pop(modalCtx), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Nama Lengkap', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 6), + TextField( + controller: nameCtrl, + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFFF8F9FE), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + prefixIcon: const Icon( + Icons.person_outline_rounded, + size: 20, + color: AppColors.primary, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + ), + const SizedBox(height: 14), + Text( + 'Nomor Telepon', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 6), + TextField( + controller: phoneCtrl, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFFF8F9FE), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + prefixIcon: const Icon( + Icons.phone_outlined, + size: 20, + color: AppColors.primary, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + ), + const SizedBox(height: 14), + Text( + 'Jenis Kelamin', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 8), + Row( + children: ['Laki-laki', 'Perempuan'].map((g) { + final isSel = selectedGender == g; + return Padding( + padding: const EdgeInsets.only(right: 10), + child: ChoiceChip( + label: Text(g), + selected: isSel, + selectedColor: AppColors.primaryContainer, + labelStyle: GoogleFonts.inter( + color: isSel ? AppColors.primary : AppColors.textSecondary, + fontWeight: isSel ? FontWeight.w700 : FontWeight.w500, + ), + onSelected: (_) { + setModalState(() => selectedGender = g); + }, + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + onPressed: () async { + final newName = nameCtrl.text.trim(); + final newPhone = phoneCtrl.text.trim(); + if (newName.isEmpty) return; + + await AppConfig.setUserProfileDetails( + name: newName, + email: _userEmail, + phone: newPhone, + gender: selectedGender, + ); + + if (mounted) { + setState(() { + _userName = newName; + _userPhone = newPhone; + _userGender = selectedGender; + }); + } + + if (modalCtx.mounted) { + Navigator.pop(modalCtx); + } + if (mounted) { + AppSnackBar.showSuccess(context, 'Profil berhasil diperbarui!'); + } + }, + child: Text( + 'Simpan Perubahan', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + void _showDeleteMemoryConfirmationDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text( + 'Hapus Memori AI?', + style: GoogleFonts.inter( + fontWeight: FontWeight.w800, + fontSize: 18, + color: AppColors.textPrimary, + ), + ), + content: Text( + 'Apakah Anda yakin ingin menghapus seluruh catatan memori AI? EVA akan menghapus riwayat konteks emosional yang telah dipelajari.', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Batal', + style: GoogleFonts.inter( + fontWeight: FontWeight.w700, + color: AppColors.textLight, + ), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD32F2F), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () { + Navigator.pop(context); + AppSnackBar.showSuccess( + context, + 'Seluruh catatan memori AI berhasil dihapus.', + ); + }, + child: Text( + 'Hapus', + style: GoogleFonts.inter( + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final canPop = Navigator.canPop(context); + + return Scaffold( + backgroundColor: AppColors.background, + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20.0, 16.0, 20.0, 100.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Back Button Navigation Header (only if pushed via route) + if (canPop) ...[ + Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Text( + 'Profil Pengguna', + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + ], + + // 1. User Profile Header Card + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Stack( + children: [ + Container( + width: 68, + height: 68, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [Color(0xFF8B93FF), Color(0xFF5358CB)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Text( + _userInitials, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: () { + AppSnackBar.show( + context, + message: 'Fitur penggantian foto profil akan segera hadir.', + ); + }, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: const Icon( + Icons.camera_alt_rounded, + size: 12, + color: Colors.white, + ), + ), + ), + ), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _userName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + _userEmail.isNotEmpty ? _userEmail : AppConfig.demoEmail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'ANGGOTA PREMIUM PRO', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 0.5, + ), + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 14), + + // 2. Coin Balance Card (Interactive Top-Up Banner) + InkWell( + onTap: () => Navigator.pushNamed(context, '/coin_purchase'), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF5358CB), Color(0xFF8B93FF)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5358CB).withValues(alpha: 0.22), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.monetization_on_rounded, + color: Color(0xFFFFD54F), + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Saldo Koin EVA', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.85), + ), + ), + Text( + '${ref.watch(coinBalanceProvider)} Koin', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.add_circle_outline_rounded, + size: 12, + color: Color(0xFF5358CB), + ), + const SizedBox(width: 4), + Text( + 'Isi Koin', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFF5358CB), + ), + ), + ], + ), + ), + const SizedBox(width: 6), + Icon( + Icons.arrow_forward_ios_rounded, + size: 11, + color: Colors.white.withValues(alpha: 0.8), + ), + ], + ), + ), + ), + + const SizedBox(height: 18), + + // 3. Wellness Activity Summary Overview + Row( + children: [ + _buildStatCard( + icon: Icons.mic_none_rounded, + title: '14 Sesi', + subtitle: 'Sesi Curhat Suara', + ), + const SizedBox(width: 10), + _buildStatCard( + icon: Icons.auto_stories_outlined, + title: '28 Entri', + subtitle: 'Refleksi Jurnal', + ), + const SizedBox(width: 10), + _buildStatCard( + icon: Icons.sentiment_satisfied_alt_rounded, + title: 'Stabil', + subtitle: 'Status Emosi', + ), + ], + ), + + const SizedBox(height: 24), + + // 3. Section: INFORMASI PRIBADI + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'INFORMASI PRIBADI', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + GestureDetector( + onTap: _showEditProfileModal, + child: Row( + children: [ + const Icon( + Icons.edit_outlined, + size: 14, + color: AppColors.primary, + ), + const SizedBox(width: 4), + Text( + 'Edit Profil', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + children: [ + _buildInfoRow( + icon: Icons.person_outline_rounded, + label: 'Nama Lengkap', + value: _userName, + ), + const Divider(height: 16, thickness: 0.6, color: Color(0xFFF1F5F9)), + _buildInfoRow( + icon: Icons.mail_outline_rounded, + label: 'Alamat Email', + value: _userEmail.isNotEmpty ? _userEmail : AppConfig.demoEmail, + badge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFECFDF5), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFA7F3D0)), + ), + child: Text( + 'Terverifikasi', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF059669), + ), + ), + ), + ), + const Divider(height: 16, thickness: 0.6, color: Color(0xFFF1F5F9)), + _buildInfoRow( + icon: Icons.phone_outlined, + label: 'Nomor Telepon', + value: _userPhone, + ), + const Divider(height: 16, thickness: 0.6, color: Color(0xFFF1F5F9)), + _buildInfoRow( + icon: Icons.cake_outlined, + label: 'Tanggal Lahir', + value: _userBirthDate, + ), + const Divider(height: 16, thickness: 0.6, color: Color(0xFFF1F5F9)), + _buildInfoRow( + icon: Icons.face_outlined, + label: 'Jenis Kelamin', + value: _userGender, + ), + const Divider(height: 16, thickness: 0.6, color: Color(0xFFF1F5F9)), + _buildInfoRow( + icon: Icons.location_on_outlined, + label: 'Domisili', + value: _userLocation, + ), + ], + ), + ), + + const SizedBox(height: 24), + + // 4. Section: AI & PRIVASI + Text( + 'AI & PRIVASI MEMORI', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFE2DAFF), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.psychology_outlined, + color: AppColors.primary, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Memori Jangka Panjang AI', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + 'EVA menyimpan konteks emosional harianmu.', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + CustomPillButton( + text: 'Hapus Catatan Memori AI', + isOutline: true, + height: 44, + onPressed: _showDeleteMemoryConfirmationDialog, + ), + ], + ), + ), + + const SizedBox(height: 24), + + // 5. Section: RENCANA KESELAMATAN KRISIS + Text( + 'RENCANA KESELAMATAN KRISIS', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: Colors.transparent, + child: ListTile( + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFFFDCDD), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.contact_phone_outlined, + color: Color(0xFFD32F2F), + size: 20, + ), + ), + title: Text( + 'Kontak Darurat Utama', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + subtitle: Text( + _emergencyContactName != null + ? '$_emergencyContactName • ${_emergencyContactPhone ?? ''}' + : 'Belum diatur • Ketuk untuk menambah', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + color: _emergencyContactName != null + ? AppColors.textSecondary + : AppColors.textLight, + ), + ), + trailing: const Icon( + Icons.arrow_forward_ios, + size: 16, + color: AppColors.textLight, + ), + onTap: () async { + await Navigator.pushNamed(context, '/emergency_contacts'); + if (mounted) { + _loadProfileData(); + } + }, + ), + ), + ), + + const SizedBox(height: 24), + + // 6. Section: PENGATURAN APLIKASI + Text( + 'PENGATURAN APLIKASI', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: Colors.transparent, + child: ListTile( + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.tune_rounded, + color: AppColors.primary, + size: 20, + ), + ), + title: Text( + 'Pengaturan & Preferensi', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + subtitle: Text( + 'Suara AI, pengingat harian, privasi & sistem', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + trailing: const Icon( + Icons.arrow_forward_ios, + size: 16, + color: AppColors.textLight, + ), + onTap: () => Navigator.pushNamed(context, '/settings'), + ), + ), + ), + + const SizedBox(height: 36), + + // 7. Logout Pill Button + SizedBox( + width: double.infinity, + height: 52, + child: OutlinedButton.icon( + onPressed: () async { + await AppConfig.clearToken(); + if (context.mounted) { + Navigator.pushNamedAndRemoveUntil(context, '/login', (route) => false); + } + }, + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFD32F2F), width: 1.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + ), + backgroundColor: Colors.transparent, + ), + icon: const Icon( + Icons.logout, + color: Color(0xFFD32F2F), + size: 20, + ), + label: Text( + 'Keluar Akun', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w700, + color: const Color(0xFFD32F2F), + ), + ), + ), + ), + + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + Widget _buildStatCard({ + required IconData icon, + required String title, + required String subtitle, + }) { + return Expanded( + child: GlassCard( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14), + child: Column( + children: [ + Icon(icon, size: 22, color: AppColors.primary), + const SizedBox(height: 6), + Text( + title, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoRow({ + required IconData icon, + required String label, + required String value, + Widget? badge, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 16, color: AppColors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textLight, + ), + ), + const SizedBox(height: 1), + Text( + value, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ?badge, + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/register_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/register_screen.dart new file mode 100644 index 0000000..0eb3531 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/register_screen.dart @@ -0,0 +1,439 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/custom_text_field.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/social_auth_dev_sheet.dart'; + +class RegisterScreen extends ConsumerStatefulWidget { + const RegisterScreen({super.key}); + + @override + ConsumerState<RegisterScreen> createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends ConsumerState<RegisterScreen> { + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + bool _agreeTerms = false; + bool _isLoading = false; + + Timer? _debounceTimer; + bool _isCheckingEmail = false; + bool? _isEmailAvailable; + String? _emailStatusMessage; + + @override + void dispose() { + _debounceTimer?.cancel(); + _nameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + void _onEmailChanged(String val) { + _debounceTimer?.cancel(); + final cleanEmail = val.trim().toLowerCase(); + + if (cleanEmail.isEmpty) { + setState(() { + _isCheckingEmail = false; + _isEmailAvailable = null; + _emailStatusMessage = null; + }); + return; + } + + final emailRegExp = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); + if (!emailRegExp.hasMatch(cleanEmail)) { + setState(() { + _isCheckingEmail = false; + _isEmailAvailable = false; + _emailStatusMessage = 'Format email belum valid'; + }); + return; + } + + setState(() { + _isCheckingEmail = true; + _isEmailAvailable = null; + _emailStatusMessage = 'Memeriksa ketersediaan email...'; + }); + + _debounceTimer = Timer(const Duration(milliseconds: 500), () async { + try { + final available = await ref.read(authRepositoryProvider).checkEmailAvailability(cleanEmail); + if (!mounted) return; + + final msg = available ? 'Email tersedia' : 'Email sudah terdaftar'; + setState(() { + _isCheckingEmail = false; + _isEmailAvailable = available; + _emailStatusMessage = msg; + }); + } catch (_) { + if (mounted) { + setState(() { + _isCheckingEmail = false; + _isEmailAvailable = null; + _emailStatusMessage = null; + }); + } + } + }); + } + + Widget? _buildEmailSuffixIcon() { + if (_isCheckingEmail) { + return const Padding( + padding: EdgeInsets.all(12.0), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + ); + } + if (_isEmailAvailable == true) { + return const Icon( + Icons.check_circle_rounded, + color: Color(0xFF2E7D32), + size: 20, + ); + } + if (_isEmailAvailable == false) { + return const Icon( + Icons.cancel_rounded, + color: Color(0xFFD32F2F), + size: 20, + ); + } + return null; + } + + Future<void> _handleRegister() async { + final name = _nameController.text.trim(); + final email = _emailController.text.trim().toLowerCase(); + final password = _passwordController.text.trim(); + + if (name.isEmpty || email.isEmpty || password.isEmpty) { + AppSnackBar.showWarning(context, 'Silakan lengkapi semua data formulir'); + return; + } + + if (_isCheckingEmail) { + AppSnackBar.showPrimary(context, 'Sedang memeriksa email, mohon tunggu...'); + return; + } + + if (_isEmailAvailable == false) { + AppSnackBar.showError( + context, + _emailStatusMessage ?? 'Email sudah terdaftar atau tidak valid', + ); + return; + } + + if (!_agreeTerms) { + AppSnackBar.showWarning(context, 'Anda harus menyetujui Syarat & Ketentuan Layanan'); + return; + } + + setState(() => _isLoading = true); + + try { + final authRepo = ref.read(authRepositoryProvider); + final user = await authRepo.register(name, email, password); + + if (!mounted) return; + AppSnackBar.showSuccess( + context, + 'Akun berhasil didaftarkan! Selamat datang di EVA, ${user.name}.', + ); + Navigator.pushReplacementNamed(context, '/home'); + } catch (e) { + if (!mounted) return; + AppSnackBar.showError( + context, + 'Registrasi gagal: ${e.toString().replaceAll('ApiException: ', '')}', + duration: const Duration(seconds: 4), + ); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF3F5FF), + Color(0xFFE9ECFF), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Brand Logo + Image.asset( + 'assets/images/eva_logo.png', + width: 50, + height: 50, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.nightlight_round, + size: 44, + color: AppColors.primary, + ); + }, + ), + const SizedBox(height: 8), + + Text( + 'Buat Akun Baru', + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + + Text( + 'Bergabung dengan EVA dan mulai merawat kesehatan mentalmu', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 24), + + // Full Name Field + CustomPillTextField( + controller: _nameController, + hintText: 'Nama Panggilan', + prefixIcon: Icons.person_outline, + ), + const SizedBox(height: 14), + + // Email Field + CustomPillTextField( + controller: _emailController, + hintText: 'Alamat Email', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + suffixIcon: _buildEmailSuffixIcon(), + onChanged: _onEmailChanged, + ), + if (_emailStatusMessage != null) ...[ + const SizedBox(height: 6), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + _emailStatusMessage!, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: _isEmailAvailable == true + ? const Color(0xFF2E7D32) + : (_isEmailAvailable == false + ? const Color(0xFFD32F2F) + : AppColors.textSecondary), + ), + ), + ), + ), + ], + const SizedBox(height: 14), + + // Password Field + CustomPillTextField( + controller: _passwordController, + hintText: 'Kata Sandi', + prefixIcon: Icons.lock_outline, + isPassword: true, + ), + const SizedBox(height: 16), + + // Terms and Conditions Checkbox + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 24, + width: 24, + child: Checkbox( + value: _agreeTerms, + activeColor: AppColors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + side: const BorderSide( + color: Color(0xFFC7D2FE), + width: 1.5, + ), + onChanged: (val) { + setState(() { + _agreeTerms = val ?? false; + }); + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text.rich( + TextSpan( + text: 'Saya menyetujui ', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + children: [ + TextSpan( + text: 'Syarat & Ketentuan', + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + const TextSpan(text: ' serta '), + TextSpan( + text: 'Kebijakan Privasi', + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 24), + + // Register Button + _isLoading + ? const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ) + : CustomPillButton( + text: 'Daftar Sekarang', + onPressed: _handleRegister, + ), + const SizedBox(height: 24), + + // Or Divider + const Row( + children: [ + Expanded(child: Divider(color: Color(0xFFD6DCF5))), + Padding( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + 'atau daftar dengan', + style: TextStyle( + fontSize: 12, + color: AppColors.textLight, + ), + ), + ), + Expanded(child: Divider(color: Color(0xFFD6DCF5))), + ], + ), + const SizedBox(height: 16), + + // Social Register Buttons Row + Row( + children: [ + Expanded( + child: SocialPillButton( + type: 'google', + onPressed: () { + showSocialAuthDevSheet(context, provider: 'Google'); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SocialPillButton( + type: 'apple', + onPressed: () { + showSocialAuthDevSheet(context, provider: 'Apple'); + }, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Login Link Footer + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Sudah punya akun? ', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + GestureDetector( + onTap: () { + Navigator.pushReplacementNamed(context, '/login'); + }, + child: Text( + 'Masuk', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), +); + } +} diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/settings_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/settings_screen.dart new file mode 100644 index 0000000..bbd89cb --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/settings_screen.dart @@ -0,0 +1,928 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/skeleton_shimmer.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State<SettingsScreen> createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State<SettingsScreen> { + // Voice & AI Preferences + String _selectedVoiceMode = 'mode_2'; + double _speechRate = 1.0; + String _conversationMode = 'Hybrid (Deteksi Otomatis)'; + + // Audio Preview Player for Voice Character Cards + final AudioPlayer _previewPlayer = AudioPlayer(); + String? _playingPreviewModeId; + + // Wellness Notifications & Reminders + bool _dailyCheckInReminder = true; + bool _eveningJournalReminder = true; + bool _breathingExerciseReminder = false; + + // Privacy & Data Security + bool _incognitoSessionMode = false; + + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSavedSettings(); + } + + Future<void> _loadSavedSettings() async { + try { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _selectedVoiceMode = prefs.getString('settings_voice_mode') ?? 'mode_2'; + _speechRate = prefs.getDouble('settings_speech_rate') ?? 1.0; + _conversationMode = prefs.getString('settings_conversation_mode') ?? 'Hybrid (Deteksi Otomatis)'; + _dailyCheckInReminder = prefs.getBool('settings_reminder_daily') ?? true; + _eveningJournalReminder = prefs.getBool('settings_reminder_evening') ?? true; + _breathingExerciseReminder = prefs.getBool('settings_reminder_breathing') ?? false; + _incognitoSessionMode = prefs.getBool('settings_incognito') ?? false; + _isLoading = false; + }); + } catch (_) { + if (mounted) setState(() => _isLoading = false); + } + } + + Future<void> _saveSetting(String key, dynamic value) async { + final prefs = await SharedPreferences.getInstance(); + if (value is String) { + await prefs.setString(key, value); + } else if (value is double) { + await prefs.setDouble(key, value); + } else if (value is bool) { + await prefs.setBool(key, value); + } + } + + @override + void dispose() { + _previewPlayer.dispose(); + super.dispose(); + } + + Future<void> _toggleAudioPreview(String modeId, String assetPath) async { + try { + if (_playingPreviewModeId == modeId) { + await _previewPlayer.stop(); + setState(() => _playingPreviewModeId = null); + return; + } + await _previewPlayer.stop(); + setState(() => _playingPreviewModeId = modeId); + final cleanPath = assetPath.startsWith('assets/') ? assetPath.substring(7) : assetPath; + await _previewPlayer.play(AssetSource(cleanPath)); + _previewPlayer.onPlayerComplete.first.then((_) { + if (mounted && _playingPreviewModeId == modeId) { + setState(() => _playingPreviewModeId = null); + } + }); + } catch (e) { + debugPrint('⚠️ [AUDIO PREVIEW ERROR] $e'); + if (mounted) { + setState(() => _playingPreviewModeId = null); + } + } + } + + void _showResetMemoryDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFFFDCDD), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.warning_amber_rounded, + color: Color(0xFFD32F2F), + size: 22, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Hapus Memori AI?', + style: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + ], + ), + content: Text( + 'Apakah Anda yakin ingin menghapus seluruh konteks percakapan emosional yang telah dipelajari EVA? Tindakan ini tidak dapat dibatalkan.', + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textSecondary, height: 1.4), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Batal', style: GoogleFonts.inter(color: AppColors.textSecondary)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD32F2F), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + ), + onPressed: () { + Navigator.pop(context); + AppSnackBar.showError( + context, + 'Memori percakapan AI berhasil dibersihkan.', + ); + }, + child: Text( + 'Ya, Hapus', + style: GoogleFonts.inter(color: Colors.white, fontWeight: FontWeight.w600), + ), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Column( + children: [ + // Header Bar with Clean Back Button + StaggeredEntrance( + index: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pengaturan & Preferensi', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Sesuaikan pengalaman terapeutik Eva', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + ), + ), + const Divider(height: 1, thickness: 0.5, color: AppColors.divider), + + // Scrollable Settings Content + Expanded( + child: _isLoading + ? const _SettingsProfileSkeletonLoader() + : SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // SECTION 1: PREFERENSI SUARA & AI + StaggeredEntrance( + index: 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader( + icon: Icons.record_voice_over_rounded, + title: 'SUARA & RESPON EVA AI', + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Karakter Suara Selector Cards + Text( + 'Karakter Suara Eva', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Pilih kepribadian vokal yang paling membuatmu merasa aman dan didengar.', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + _buildVoiceChoiceTile( + title: 'Eva - Jessica Ceria (Default)', + subtitle: 'Suara ramah, hangat, dan optimis dengan nada yang lebih ceria (Jessica + Happily)', + icon: Icons.sentiment_very_satisfied_rounded, + isSelected: _selectedVoiceMode == 'mode_2', + isDefault: true, + isPlayingPreview: _playingPreviewModeId == 'mode_2', + onPreviewTap: () => _toggleAudioPreview('mode_2', 'assets/audio/mode_2_jessica_ceria.mp3'), + onTap: () { + setState(() => _selectedVoiceMode = 'mode_2'); + _saveSetting('settings_voice_mode', 'mode_2'); + _saveSetting('settings_voice_character', 'Eva - Jessica Ceria (Default)'); + }, + ), + const SizedBox(height: 8), + _buildVoiceChoiceTile( + title: 'Eva - Jessica Playful', + subtitle: 'Suara lincah, ekspresif, manis, santai, dan akrab (Jessica + Playful)', + icon: Icons.mood_rounded, + isSelected: _selectedVoiceMode == 'mode_4', + isPlayingPreview: _playingPreviewModeId == 'mode_4', + onPreviewTap: () => _toggleAudioPreview('mode_4', 'assets/audio/mode_4_jessica_playful.mp3'), + onTap: () { + setState(() => _selectedVoiceMode = 'mode_4'); + _saveSetting('settings_voice_mode', 'mode_4'); + _saveSetting('settings_voice_character', 'Eva - Jessica Playful'); + }, + ), + const SizedBox(height: 8), + _buildVoiceChoiceTile( + title: 'Eva - Laura Muda & Enerjik', + subtitle: 'Suara perempuan muda, cerdas, artikulatif, dan dinamis (Laura + Happily)', + icon: Icons.bolt_rounded, + isSelected: _selectedVoiceMode == 'mode_7', + isPlayingPreview: _playingPreviewModeId == 'mode_7', + onPreviewTap: () => _toggleAudioPreview('mode_7', 'assets/audio/mode_7_laura_enerjik.mp3'), + onTap: () { + setState(() => _selectedVoiceMode = 'mode_7'); + _saveSetting('settings_voice_mode', 'mode_7'); + _saveSetting('settings_voice_character', 'Eva - Laura Muda & Enerjik'); + }, + ), + const SizedBox(height: 16), + const Divider(height: 1, thickness: 0.5, color: AppColors.divider), + const SizedBox(height: 14), + + // Kecepatan Bicara Slider + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Tempo Kecepatan Suara', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${_speechRate.toStringAsFixed(2)}x', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: AppColors.primary, + inactiveTrackColor: AppColors.primaryContainer, + thumbColor: AppColors.primary, + overlayColor: AppColors.primary.withValues(alpha: 0.15), + trackHeight: 4, + ), + child: Slider( + value: _speechRate, + min: 0.8, + max: 1.2, + divisions: 4, + label: '${_speechRate.toStringAsFixed(2)}x', + onChanged: (val) { + setState(() => _speechRate = val); + _saveSetting('settings_speech_rate', val); + }, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Lambat (0.8x)', style: GoogleFonts.inter(fontSize: 10, color: AppColors.textLight)), + Text('Normal (1.0x)', style: GoogleFonts.inter(fontSize: 10, color: AppColors.textLight)), + Text('Cepat (1.2x)', style: GoogleFonts.inter(fontSize: 10, color: AppColors.textLight)), + ], + ), + const SizedBox(height: 16), + const Divider(height: 1, thickness: 0.5, color: AppColors.divider), + const SizedBox(height: 14), + + // Mode Percakapan Suara + Text( + 'Metode Interaksi Suara', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildVoiceChoiceTile( + title: 'Hybrid (Deteksi Suara Otomatis)', + subtitle: 'Cukup berbicara langsung, Eva akan mendengarkan', + icon: Icons.graphic_eq_rounded, + isSelected: _conversationMode == 'Hybrid (Deteksi Otomatis)', + onTap: () { + setState(() => _conversationMode = 'Hybrid (Deteksi Otomatis)'); + _saveSetting('settings_conversation_mode', 'Hybrid (Deteksi Otomatis)'); + }, + ), + const SizedBox(height: 8), + _buildVoiceChoiceTile( + title: 'Push-To-Talk (Manual)', + subtitle: 'Tahan tombol untuk bicara, lepas untuk kirim suara', + icon: Icons.touch_app_rounded, + isSelected: _conversationMode == 'Push-To-Talk (Manual)', + onTap: () { + setState(() => _conversationMode = 'Push-To-Talk (Manual)'); + _saveSetting('settings_conversation_mode', 'Push-To-Talk (Manual)'); + }, + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // SECTION 2: PENGINGAT & KESEHATAN MENTAL + StaggeredEntrance( + index: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader( + icon: Icons.notifications_active_rounded, + title: 'PENGINGAT RUTINITAS WELLNESS', + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildSwitchTile( + icon: Icons.chat_bubble_outline_rounded, + title: 'Pengingat Sesi Curhat Pagi', + subtitle: 'Notifikasi ramah untuk menyapa emosimu saat memulai hari', + value: _dailyCheckInReminder, + onChanged: (val) { + setState(() => _dailyCheckInReminder = val); + _saveSetting('settings_reminder_daily', val); + }, + ), + const Divider(height: 20, thickness: 0.5, color: AppColors.divider), + _buildSwitchTile( + icon: Icons.menu_book_rounded, + title: 'Refleksi Jurnal Malam', + subtitle: 'Pengingat membaca sintesis emosi harian pukul 20:00 WIB', + value: _eveningJournalReminder, + onChanged: (val) { + setState(() => _eveningJournalReminder = val); + _saveSetting('settings_reminder_evening', val); + }, + ), + const Divider(height: 20, thickness: 0.5, color: AppColors.divider), + _buildSwitchTile( + icon: Icons.air_rounded, + title: 'Latihan Napas & Relaksasi', + subtitle: 'Ajakan latihan pernapasan 4-7-8 untuk menstabilkan detak jantung', + value: _breathingExerciseReminder, + onChanged: (val) { + setState(() => _breathingExerciseReminder = val); + _saveSetting('settings_reminder_breathing', val); + }, + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // SECTION 3: PRIVASI & KEAMANAN DATA + StaggeredEntrance( + index: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader( + icon: Icons.shield_outlined, + title: 'PRIVASI & KEAMANAN DATA', + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildSwitchTile( + icon: Icons.visibility_off_outlined, + title: 'Mode Sesi Privat (Incognito)', + subtitle: 'Sesi dialog tidak dicatat ke memori jangka panjang AI', + value: _incognitoSessionMode, + onChanged: (val) { + setState(() => _incognitoSessionMode = val); + _saveSetting('settings_incognito', val); + }, + ), + const Divider(height: 20, thickness: 0.5, color: AppColors.divider), + _buildActionTile( + icon: Icons.delete_outline_rounded, + iconColor: const Color(0xFFD32F2F), + title: 'Hapus Memori AI Eva', + subtitle: 'Bersihkan seluruh riwayat konteks emosional yang tersimpan', + onTap: _showResetMemoryDialog, + ), + const Divider(height: 20, thickness: 0.5, color: AppColors.divider), + _buildActionTile( + icon: Icons.contact_phone_outlined, + iconColor: AppColors.primary, + title: 'Kontak Darurat & Protokol Krisis', + subtitle: 'Kelola kontak darurat terpercaya dan hotline resmi 24 jam', + onTap: () => Navigator.pushNamed(context, '/emergency_contacts'), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // SECTION 4: TENTANG APLIKASI + StaggeredEntrance( + index: 4, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader( + icon: Icons.info_outline_rounded, + title: 'TENTANG APLIKASI', + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildActionTile( + icon: Icons.description_outlined, + iconColor: AppColors.primary, + title: 'Syarat, Ketentuan & Disclaimer Medis', + subtitle: 'Pelajari batasan klinis dan privasi data pengguna', + onTap: () => Navigator.pushNamed(context, '/terms_disclaimer'), + ), + const Divider(height: 20, thickness: 0.5, color: AppColors.divider), + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.verified_outlined, size: 18, color: AppColors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Versi Aplikasi', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + 'EVA AI v1.0.4 • Build 2026.09 (Production)', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFD1FAE5), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Terbaru', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF047857), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSectionHeader({required IconData icon, required String title}) { + return Row( + children: [ + Icon(icon, size: 14, color: AppColors.primary), + const SizedBox(width: 6), + Text( + title, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 0.8, + ), + ), + ], + ); + } + + Widget _buildVoiceChoiceTile({ + required String title, + required String subtitle, + required IconData icon, + required bool isSelected, + required VoidCallback onTap, + bool isDefault = false, + bool isPlayingPreview = false, + VoidCallback? onPreviewTap, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFFF0FDF4) : Colors.white.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? const Color(0xFF10B981) : const Color(0xFFE2E8F0), + width: isSelected ? 1.5 : 1.0, + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFFD1FAE5) : const Color(0xFFF1F5F9), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 16, + color: isSelected ? const Color(0xFF047857) : AppColors.textLight, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + if (isDefault) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + decoration: BoxDecoration( + color: const Color(0xFFD1FAE5), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'DEFAULT', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w800, + color: const Color(0xFF047857), + letterSpacing: 0.4, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + if (onPreviewTap != null) ...[ + IconButton( + onPressed: onPreviewTap, + icon: Icon( + isPlayingPreview ? Icons.stop_circle_rounded : Icons.play_circle_fill_rounded, + color: isPlayingPreview ? const Color(0xFFEF4444) : const Color(0xFF10B981), + size: 26, + ), + tooltip: isPlayingPreview ? 'Hentikan sampel' : 'Dengarkan sampel', + constraints: const BoxConstraints(), + padding: const EdgeInsets.symmetric(horizontal: 6), + ), + ], + Icon( + isSelected ? Icons.check_circle_rounded : Icons.radio_button_off_rounded, + size: 20, + color: isSelected ? const Color(0xFF10B981) : AppColors.textLight, + ), + ], + ), + ), + ), + ); + } + + Widget _buildSwitchTile({ + required IconData icon, + required String title, + required String subtitle, + required bool value, + required ValueChanged<bool> onChanged, + }) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: AppColors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Switch.adaptive( + value: value, + activeThumbColor: const Color(0xFF10B981), + activeTrackColor: const Color(0xFFD1FAE5), + onChanged: onChanged, + ), + ], + ); + } + + Widget _buildActionTile({ + required IconData icon, + required Color iconColor, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: iconColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right, size: 18, color: AppColors.textLight), + ], + ), + ), + ); + } +} + +/// Shimmer skeleton loader for Settings screen. +class _SettingsProfileSkeletonLoader extends StatelessWidget { + const _SettingsProfileSkeletonLoader(); + + @override + Widget build(BuildContext context) { + return SkeletonShimmerHost( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section 1: Voice & AI Preferences + Row( + children: const [ + SkeletonCircle(size: 20), + SizedBox(width: 8), + SkeletonLine(width: 140, height: 13), + ], + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 120, height: 14), + SizedBox(height: 12), + SkeletonBox(width: double.infinity, height: 44, borderRadius: 10), + SizedBox(height: 16), + SkeletonLine(width: 100, height: 14), + SizedBox(height: 12), + SkeletonBox(width: double.infinity, height: 28, borderRadius: 10), + ], + ), + ), + const SizedBox(height: 24), + + // Section 2: Notifications & Reminders + Row( + children: const [ + SkeletonCircle(size: 20), + SizedBox(width: 8), + SkeletonLine(width: 170, height: 13), + ], + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + children: List.generate( + 3, + (index) => Padding( + padding: EdgeInsets.only(bottom: index == 2 ? 0 : 16.0), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 140, height: 14), + SizedBox(height: 6), + SkeletonLine(width: 190, height: 11), + ], + ), + ), + const SkeletonBox(width: 44, height: 24, borderRadius: 12), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/splash_onboarding_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/splash_onboarding_screen.dart new file mode 100644 index 0000000..025d164 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/splash_onboarding_screen.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import '../providers/auth_provider.dart'; + +class SplashOnboardingScreen extends ConsumerStatefulWidget { + const SplashOnboardingScreen({super.key}); + + @override + ConsumerState<SplashOnboardingScreen> createState() => _SplashOnboardingScreenState(); +} + +class _SplashOnboardingScreenState extends ConsumerState<SplashOnboardingScreen> { + bool _isCheckingAuth = true; + + @override + void initState() { + super.initState(); + // Sementara dinonaktifkan agar langsung masuk ke /home tanpa mengganggu alur tutorial + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + Navigator.pushReplacementNamed(context, '/home'); + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + precacheImage(const AssetImage('assets/images/eva_logo.png'), context); + } + + Future<void> _checkExistingSession() async { + final token = await AppConfig.getToken(); + final refreshToken = await AppConfig.getRefreshToken(); + + if (token == null && refreshToken == null) { + if (mounted) { + setState(() => _isCheckingAuth = false); + } + return; + } + + bool isAuthenticated = false; + + // 1. Try verifying access token with AuthRepository + try { + if (token != null && token.isNotEmpty) { + final user = await ref.read(authRepositoryProvider).getCurrentUser(); + if (user != null) { + await AppConfig.setUserInfo( + name: user.name, + email: user.email, + ); + isAuthenticated = true; + } + } + } catch (_) {} + + // 2. If access token is invalid/expired, attempt refresh token + if (!isAuthenticated && refreshToken != null && refreshToken.isNotEmpty) { + isAuthenticated = await AppConfig.refreshAccessToken(); + } + + if (!mounted) return; + + if (isAuthenticated) { + Navigator.pushReplacementNamed(context, '/home'); + } else { + // Clear invalid local credentials + if (token != null || refreshToken != null) { + await AppConfig.clearToken(); + } + if (mounted) { + setState(() => _isCheckingAuth = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF3F5FF), + Color(0xFFE9ECFF), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + child: _isCheckingAuth + ? _buildMinimalistSplash() + : _buildOnboardingContent(), + ), + ), + ), + ); + } + + /// Minimalist native-like splash screen displaying the centered app icon and branding. + Widget _buildMinimalistSplash() { + return Center( + key: const ValueKey('minimalist_splash'), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/images/eva_logo.png', + width: 72, + height: 72, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.nightlight_round, + size: 64, + color: AppColors.primary, + ); + }, + ), + const SizedBox(height: 16), + Text( + 'EVA', + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 4), + Text( + 'PENDAMPING KESEHATAN MENTAL', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.primary.withValues(alpha: 0.7), + letterSpacing: 1.8, + ), + ), + ], + ), + ); + } + + /// Onboarding content card shown after session verification when user is unauthenticated. + Widget _buildOnboardingContent() { + return LayoutBuilder( + key: const ValueKey('onboarding_content'), + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + children: [ + const SizedBox(height: 16), + + // Top Logo & Brand + Image.asset( + 'assets/images/eva_logo.png', + width: 44, + height: 44, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.nightlight_round, + size: 40, + color: AppColors.primary, + ); + }, + ), + const SizedBox(height: 4), + Text( + 'EVA', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 1.2, + ), + ), + Text( + 'PENDAMPING KESEHATAN MENTAL', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.primary.withValues(alpha: 0.7), + letterSpacing: 1.5, + ), + ), + + const Spacer(), + + // Center 3D Glowing Orb Visual + Center( + child: Container( + width: 220, + height: 220, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const SweepGradient( + colors: [ + Color(0xFFFFB6C1), + Color(0xFFE2DAFF), + Color(0xFFA7E6FF), + Color(0xFF8B93FF), + Color(0xFFFFB6C1), + ], + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.3), + blurRadius: 40, + spreadRadius: 5, + ), + ], + ), + child: Container( + margin: const EdgeInsets.all(3), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + Colors.white, + Color(0xFFEADBFF), + Color(0xFFA7E6FF), + ], + center: Alignment(-0.3, -0.3), + radius: 0.8, + ), + ), + ), + ), + ), + + const Spacer(), + + // Bottom Glass Card Onboarding Content + GlassCard( + padding: const EdgeInsets.all(28.0), + child: Column( + children: [ + Text( + 'Selamat Datang di EVA', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 12), + Text( + 'Ruang aman tempat pikiranmu didengar, dipahami, dan dirawat tanpa penilaian.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 24), + + // Primary Button: Start Your Journey + CustomPillButton( + text: 'Mulai Perjalananmu', + onPressed: () { + Navigator.pushNamed(context, '/register'); + }, + ), + const SizedBox(height: 14), + + // Login Link + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Sudah punya akun? ', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/login'); + }, + child: Text( + 'Masuk', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/terms_disclaimer_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/terms_disclaimer_screen.dart new file mode 100644 index 0000000..be70b86 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/terms_disclaimer_screen.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; + +class TermsDisclaimerScreen extends StatelessWidget { + const TermsDisclaimerScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF3F5FF), + Color(0xFFE9ECFF), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Column( + children: [ + // Custom Header Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Text( + 'Syarat & Disclaimer Etika', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + + // Main Content ScrollView + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 8.0), + child: Column( + children: [ + // Header Card + GlassCard( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.gavel_rounded, + color: AppColors.primary, + size: 26, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Eva AI Ethics & Terms', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Panduan Etika, Batasan Medis & Kebijakan Layanan', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Section 1: Prinsip Etika Layanan + _buildSectionCard( + icon: Icons.favorite_border_rounded, + iconColor: const Color(0xFF6C5CE7), + title: '1. Prinsip Etika Konseling AI', + content: [ + 'Non-Judgmental Space: Eva AI dirancang untuk menyediakan ruang refleksi emosional yang aman tanpa penilaian moral atau preskriptif.', + 'Empati CBT Ringan: Bahasa interaksi mengadopsi pendekatan Cognitive Behavioral Therapy (CBT) ringan untuk membantu pengguna memvalidasi emosi secara sehat.', + 'Aksesibilitas 24/7: Layanan ini beroperasi 24 jam sehari untuk mendampingi kesehatan mental harian pengguna.', + ], + ), + const SizedBox(height: 16), + + // Section 2: Batasan Wewenang Medis (Medical Disclaimer) + _buildSectionCard( + icon: Icons.health_and_safety_outlined, + iconColor: const Color(0xFFD32F2F), + title: '2. Batasan Wewenang Medis (Medical Disclaimer)', + content: [ + 'Bukan Tenaga Medis Berlisensi: Eva AI adalah sistem pendukung berbasis kecerdasan buatan, BUKAN psikolog klinis, psikiater, atau fasilitas kesehatan manusia.', + 'Tidak Berwewenang Memberikan Diagnosis: Eva AI TIDAK memberikan diagnosis klinis resmi (seperti Depresi Klinis, Bipolar, Skizofrenia, atau Gangguan Kecemasan Akut).', + 'Tanpa Resep Obat: Eva AI TIDAK BERWEWENANG mengeluarakan atau menyarankan resep obat-obatan psikiatri/medikal.', + 'Pengganti Terapi Nyata: Layanan ini tidak dimaksudkan untuk menggantikan sesi terapi langsung dengan profesional kesehatan mental berlisensi.', + ], + highlightColor: const Color(0xFFFFF5F5), + ), + const SizedBox(height: 16), + + // Section 3: Protokol Keselamatan & Penanganan Krisis + _buildSectionCard( + icon: Icons.shield_outlined, + iconColor: const Color(0xFFE67E22), + title: '3. Protokol Keselamatan Emosional & Krisis', + content: [ + 'Multi-layered Safety Gate: Sistem memindai pesan secara real-time untuk mendeteksi indikasi risiko krisis emosional atau kecenderungan mencederai diri (Self-Harm / Suicidal Ideation).', + 'Eskalasi Krisis Otomatis: Apabila terdeteksi indikasi krisis tinggi (High Risk), sesi konseling standar akan dihentikan dan sistem secara otomatis memicu halaman penanganan krisis darurat.', + 'Kontak Darurat & Hotline: Pengguna akan diarahkan ke Hotline Kesehatan Mental Resmi (Layanan Sejiwa 119 ext 8) serta opsi untuk menghubungkan Kontak Darurat Utama.', + ], + ), + const SizedBox(height: 16), + + // Section 4: Kebijakan Privasi & Etika Penggunaan + _buildSectionCard( + icon: Icons.lock_outline_rounded, + iconColor: const Color(0xFF00B894), + title: '4. Kerahasiaan Data & Etika Penggunaan', + content: [ + 'Kerahasiaan Percakapan: Data sesi percakapan dan jurnal Anda dilindungi dan tidak dijual atau dibagikan kepada pihak ketiga non-medis tanpa persetujuan Anda.', + 'Tata Tertib Penggunaan: Pengguna dilarang memanfaatkan AI untuk mempromosikan tindakan ilegal, membahayakan orang lain, atau melakukan peretasan prompt (jailbreaking).', + 'Sintesis Jurnal Otomatis: Ringkasan emosional dibuat otomatis untuk membantu pengguna memantau perkembangan kesejahteraan mental secara mandiri.', + ], + ), + const SizedBox(height: 24), + + // Footer Statement + Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Text( + 'Dengan menggunakan aplikasi Eva, Anda menyatakan telah membaca, memahami, dan menyetujui seluruh ketentuan etika dan batasan layanan di atas.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSectionCard({ + required IconData icon, + required Color iconColor, + required String title, + required List<String> content, + Color? highlightColor, + }) { + return GlassCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: iconColor, size: 22), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1, color: Color(0xFFE2E8F0)), + const SizedBox(height: 12), + ...content.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '• ', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.bold, + color: iconColor, + ), + ), + Expanded( + child: Text( + item, + style: GoogleFonts.inter( + fontSize: 12.5, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/data/datasources/care_datasource.dart b/apps/eva_mobile/lib/features/care/data/datasources/care_datasource.dart new file mode 100644 index 0000000..cd5320f --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/datasources/care_datasource.dart @@ -0,0 +1,19 @@ +import '../../domain/entities/consultation_session_entity.dart'; +import '../models/psychologist_model.dart'; +import '../models/clinic_model.dart'; +import '../models/community_status_model.dart'; +import '../models/consultation_session_model.dart'; + +abstract class CareDataSource { + Future<List<PsychologistModel>> getPsychologists({String? category, String? query}); + Future<List<ClinicModel>> getClinics({String? query}); + Future<CommunityStatusModel> getCommunityStatus(); + Future<CommunityStatusModel> joinCommunityWithVipTrial(); + Future<List<ConsultationSessionModel>> getConsultationSessions(); + Future<ConsultationSessionModel> bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }); +} diff --git a/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart b/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart new file mode 100644 index 0000000..d9d84f5 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart @@ -0,0 +1,863 @@ +import 'dart:async'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../models/psychologist_model.dart'; +import '../models/clinic_model.dart'; +import '../models/community_status_model.dart'; +import '../models/consultation_session_model.dart'; +import 'care_datasource.dart'; + +class MockCareDataSource implements CareDataSource { + // In-memory state for reactive demo & offline support + CommunityStatusModel _communityStatus = const CommunityStatusModel( + isMember: false, + isVipActive: false, + vipRemainingDays: 0, + serverName: 'Eva Safe Space ID', + inviteUrl: 'https://discord.gg/evasafespace', + memberCount: 1420, + ); + + final List<PsychologistModel> _psychologists = [ + const PsychologistModel( + id: 'psy_1', + name: 'Amanda Zahra, M.Psi., Psikolog', + title: 'Psikolog Klinis Dewasa', + registrationNumber: 'SIPP: 1988-12-04-2021', + avatarUrl: 'https://images.unsplash.com/photo-1594824813583-059df30f1484?w=400&auto=format&fit=crop&q=80', + rating: 4.95, + reviewCount: 68, + experienceYears: 6, + practitionerType: PractitionerType.professional, + hasPsychologistCertificate: true, + isVolunteerRole: true, + specialties: ['Kecemasan', 'Quarter-Life Crisis', 'Burnout Karir', 'Self-Esteem'], + patientReviewTags: [ + 'Sangat Sabar & Empati', + 'Teknik CBT Mudah Dipahami', + 'Slot Jadwal Cepat Penuh', + 'Ruang Cerita Bebas Menghakimi', + ], + reviewTags: [ + PatientReviewTag(label: 'Sangat Sabar & Empati', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Teknik CBT Mudah Dipahami', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Slot Jadwal Cepat Penuh', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Ruang Cerita Bebas Menghakimi', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Waktu 50 Mnt Terasa Singkat', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_1_1', + patientName: 'Pasien Terverifikasi (A. Z.)', + sentiment: ReviewSentiment.positive, + label: 'Sangat Sabar & Empati', + description: 'Kak Amanda sangat sabar mendengarkan ceritaku tanpa menghakimi. Setelah sesi, rasanya beban pikiran berkurang banyak dan overthinking terkontrol.', + rating: 5.0, + timeAgo: '2 hari lalu', + ), + PatientReviewItem( + id: 'rev_1_2', + patientName: 'Pasien Terverifikasi (R. K.)', + sentiment: ReviewSentiment.positive, + label: 'Teknik CBT Mudah Dipahami', + description: 'Latihan grounding dan teknik CBT yang diajarkan sangat membantu meredakan panik saat bekerja di kantor. Penjelasannya sangat runut dan praktis.', + rating: 5.0, + timeAgo: '5 hari lalu', + ), + PatientReviewItem( + id: 'rev_1_3', + patientName: 'Pasien Terverifikasi (M. F.)', + sentiment: ReviewSentiment.negative, + label: 'Slot Jadwal Cepat Penuh', + description: 'Kualitas konselingnya luar biasa bermanfaat, hanya saja antrean slot jadwal cepat sekali penuh sehingga perlu booking dari jauh hari.', + rating: 4.0, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_1_4', + patientName: 'Pasien Terverifikasi (N. D.)', + sentiment: ReviewSentiment.positive, + label: 'Ruang Cerita Bebas Menghakimi', + description: 'Pertama kali konsultasi dan sempat cemas, tapi Kak Amanda langsung menciptakan safe space yang menenangkan untuk membedah luka batin.', + rating: 5.0, + timeAgo: '2 minggu lalu', + ), + PatientReviewItem( + id: 'rev_1_5', + patientName: 'Pasien Terverifikasi (H. P.)', + sentiment: ReviewSentiment.positive, + label: 'Rencana Aksi Sangat Konkret', + description: 'Diberikan action plan mingguan yang realistis untuk mengatasi quarter-life crisis. Sangat terasa perbedaannya dalam 3 sesi berturut-turut.', + rating: 4.8, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Kak Amanda sangat sabar mendengarkan ceritaku tanpa menghakimi. Setelah sesi, rasanya beban pikiran berkurang banyak.', + 'Latihan grounding dan teknik CBT yang diajarkan sangat membantu meredakan panik saat bekerja.', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 284, + totalSessionsCompleted: 490, + satisfactionRate: 98.6, + positiveReviewRate: 96.2, + recoveryRate: 94.5, + avgRecoverySessions: 3.8, + recoveryDescription: '94.5% pasien merasakan penurunan kecemasan & stres kerja signifikan', + ), + certifications: [ + 'SIPP: 1988-12-04-2021 (HIMPSI)', + 'Magister Profesi Psikologi Klinis Dewasa (S2)', + 'Sertifikasi CBT Terapan & Regulasi Emosi', + 'Sertifikat Psychological First Aid (PFA) Nasional', + ], + coinsPerSession: 50, + durationMinutes: 50, + availableOnline: true, + clinicAffiliation: 'RS Mitra Medika & Klinik Sahabat Jiwa', + bio: 'Fokus pada pendekatan Cognitive Behavioral Therapy (CBT) dan Acceptance and Commitment Therapy (ACT) untuk membantu individu mengatasi stres kerja, overthinking, dan krisis identitas.', + ), + const PsychologistModel( + id: 'psy_2', + name: 'Dimas Wicaksono, M.Psi., Psikolog', + title: 'Psikolog Klinis & Spesialis Karir', + registrationNumber: 'SIPP: 1985-07-15-2019', + avatarUrl: 'https://images.unsplash.com/photo-1622253692010-333f2da6031d?w=400&auto=format&fit=crop&q=80', + rating: 4.92, + reviewCount: 94, + experienceYears: 8, + practitionerType: PractitionerType.professional, + hasPsychologistCertificate: true, + isVolunteerRole: false, + specialties: ['Burnout & Stres Kerja', 'Depresi Ringan', 'Resolusi Konflik', 'Manajemen Emosi'], + patientReviewTags: [ + 'Solutif & Terarah', + 'Bikin Pikiran Lebih Terang', + 'Ekspektasi Terlalu Tinggi di Awal', + 'Profesional & Disiplin', + ], + reviewTags: [ + PatientReviewTag(label: 'Solutif & Terarah', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Bikin Pikiran Lebih Terang', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Ekspektasi Terlalu Tinggi di Awal', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Profesional & Disiplin', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Tugas Refleksi Cukup Banyak', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_2_1', + patientName: 'Pasien Terverifikasi (T. B.)', + sentiment: ReviewSentiment.positive, + label: 'Solutif & Terarah', + description: 'Mas Dimas memberikan sudut pandang yang sangat logis dan menenangkan saat aku burnout parah di tempat kerja korporat.', + rating: 5.0, + timeAgo: '1 hari lalu', + ), + PatientReviewItem( + id: 'rev_2_2', + patientName: 'Pasien Terverifikasi (K. W.)', + sentiment: ReviewSentiment.positive, + label: 'Bikin Pikiran Lebih Terang', + description: 'Sesi berjalan sangat terstruktur. Saya langsung dapat rencana aksi konkret untuk menghadapi atasan toxic dan menata ulang work-life balance.', + rating: 5.0, + timeAgo: '4 hari lalu', + ), + PatientReviewItem( + id: 'rev_2_3', + patientName: 'Pasien Terverifikasi (V. J.)', + sentiment: ReviewSentiment.negative, + label: 'Tugas Refleksi Cukup Padat', + description: 'Pendekatannya sangat direct dan berorientasi hasil, namun tugas refleksi mandiri di antara sesi lumayan padat untuk orang yang sedang lelah mental.', + rating: 4.0, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_2_4', + patientName: 'Pasien Terverifikasi (E. L.)', + sentiment: ReviewSentiment.positive, + label: 'Profesional & Disiplin', + description: 'Sangat tepat waktu, komunikatif, dan tidak bertele-tele. Sesi 50 menit dimanfaatkan secara maksimal untuk akar masalah.', + rating: 5.0, + timeAgo: '3 minggu lalu', + ), + PatientReviewItem( + id: 'rev_2_5', + patientName: 'Pasien Terverifikasi (D. S.)', + sentiment: ReviewSentiment.positive, + label: 'Regulasi Emosi Terasa Nyata', + description: 'Teknik SFBT dari Mas Dimas menyelamatkan karir dan relasi saya dengan rekan kantor. Kondisi depresi ringan berangsur pulih.', + rating: 4.8, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Mas Dimas memberikan sudut pandang yang sangat logis dan menenangkan saat aku burnout parah.', + 'Sesi berjalan sangat nyaman dan langsung dapat rencana aksi konkret untuk minggu depan.', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 412, + totalSessionsCompleted: 780, + satisfactionRate: 98.2, + positiveReviewRate: 95.7, + recoveryRate: 93.8, + avgRecoverySessions: 4.1, + recoveryDescription: '93.8% pasien pulih dari sindrom kelelahan kerja (burnout) & stres kronis', + ), + certifications: [ + 'SIPP: 1985-07-15-2019 (HIMPSI)', + 'Magister Profesi Psikologi Klinis & Industri (S2)', + 'Sertifikasi Solution-Focused Brief Therapy (SFBT)', + 'Certified Workplace Wellness Specialist', + ], + coinsPerSession: 60, + durationMinutes: 50, + availableOnline: true, + clinicAffiliation: 'RS Jiwa & Emosi Harmoni', + bio: 'Pendekatan solusi terfokus (SFBT) dan regulasi emosi untuk membantu profesional muda menyeimbangkan karir dan kedamaian mental.', + ), + const PsychologistModel( + id: 'psy_3', + name: 'Rian Pratama, M.Psi., Psikolog', + title: 'Psikolog Konseling Hubungan & Keluarga', + registrationNumber: 'SIPP: 1991-05-18-2022', + avatarUrl: 'https://images.unsplash.com/photo-1537368910025-700350fe46c7?w=400&auto=format&fit=crop&q=80', + rating: 4.88, + reviewCount: 45, + experienceYears: 5, + practitionerType: PractitionerType.freelance, + hasPsychologistCertificate: true, + isVolunteerRole: true, + specialties: ['Dinamika Hubungan', 'Luka Batin Masa Lalu', 'Komunikasi Pasangan', 'Grief'], + patientReviewTags: [ + 'Pendengar yang Sangat Baik', + 'Suasana Hangat & Terbuka', + 'Waktu Bicara Cepat Usai', + 'Sangat Menghargai Cerita', + ], + reviewTags: [ + PatientReviewTag(label: 'Pendengar yang Sangat Baik', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Suasana Hangat & Terbuka', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Waktu Bicara Cepat Usai', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Sangat Menghargai Cerita', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Jadwal Akhir Pekan Terbatas', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_3_1', + patientName: 'Pasien Terverifikasi (Y. S.)', + sentiment: ReviewSentiment.positive, + label: 'Pendengar yang Sangat Baik', + description: 'Kak Rian sangat hangat dan membuat aku merasa aman untuk menceritakan masalah keluarga yang selama ini kupendam bertahun-tahun.', + rating: 5.0, + timeAgo: '3 hari lalu', + ), + PatientReviewItem( + id: 'rev_3_2', + patientName: 'Pasien Terverifikasi (B. H.)', + sentiment: ReviewSentiment.positive, + label: 'Suasana Hangat & Terbuka', + description: 'Gaya bicaranya tenang dan tidak mendikte, membuat saya secara alami menyadari pola komunikasi saya yang keliru dengan pasangan.', + rating: 5.0, + timeAgo: '6 hari lalu', + ), + PatientReviewItem( + id: 'rev_3_3', + patientName: 'Pasien Terverifikasi (F. M.)', + sentiment: ReviewSentiment.negative, + label: 'Waktu Bicara Cepat Usai', + description: 'Konselingnya luar biasa nyaman, namun karena topiknya mendalam rasanya durasi 50 menit berlalu terlalu cepat sebelum semua tersampaikan.', + rating: 4.2, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_3_4', + patientName: 'Pasien Terverifikasi (R. A.)', + sentiment: ReviewSentiment.positive, + label: 'Sangat Menghargai Cerita', + description: 'Sangat suportif saat mendampingi proses berduka (grief). Tiap tangisan divalidasi dan diberi ruang tanpa dipaksa cepat-cepat move on.', + rating: 5.0, + timeAgo: '2 minggu lalu', + ), + PatientReviewItem( + id: 'rev_3_5', + patientName: 'Pasien Terverifikasi (A. M.)', + sentiment: ReviewSentiment.positive, + label: 'Penyembuhan Luka Batin', + description: 'Latihan rekonsiliasi emosional yang dibimbing Kak Rian sangat membantu saya memaafkan trauma masa kecil secara bertahap.', + rating: 4.8, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Kak Rian sangat hangat dan membuat aku merasa aman untuk menceritakan masalah keluarga yang selama ini kupendam.', + 'Gaya bicaranya tenang dan tidak mendikte, membuat saya sadar akar masalah saya sendiri.', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 215, + totalSessionsCompleted: 395, + satisfactionRate: 97.9, + positiveReviewRate: 95.3, + recoveryRate: 92.4, + avgRecoverySessions: 3.5, + recoveryDescription: '92.4% pasien melaporkan resolusi konflik hubungan & penerimaan luka batin', + ), + certifications: [ + 'SIPP: 1991-05-18-2022 (HIMPSI)', + 'Magister Profesi Psikologi Klinis (S2)', + 'Praktisi Psikolog Mandiri / Freelance Terdaftar', + 'Sertifikat Konseling Krisis & Relawan Komunitas', + ], + coinsPerSession: 40, + durationMinutes: 50, + availableOnline: true, + clinicAffiliation: 'Freelance', + bio: 'Menggunakan pendekatan humanistik dan mindfulness untuk memfasilitasi rekonsiliasi emosional serta penyembuhan luka batin relasional.', + ), + const PsychologistModel( + id: 'psy_4', + name: 'Nathania Stephanie, S.Psi., M.Psi.', + title: 'Psikolog Konseling & Mindfulness Coach', + registrationNumber: 'SIPP: 1994-08-22-2023', + avatarUrl: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&auto=format&fit=crop&q=80', + rating: 4.90, + reviewCount: 39, + experienceYears: 4, + practitionerType: PractitionerType.freelance, + hasPsychologistCertificate: true, + isVolunteerRole: false, + specialties: ['Mindfulness Terapan', 'Manajemen Emosi', 'Inner Child Healing', 'Kecemasan Sosial'], + patientReviewTags: [ + 'Menyejukkan Hati', + 'Latihan Pernapasan Efektif', + 'Perlu Latihan Mandiri Konsisten', + 'Bikin Hati Tenang', + ], + reviewTags: [ + PatientReviewTag(label: 'Menyejukkan Hati', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Latihan Pernapasan Efektif', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Perlu Latihan Mandiri Konsisten', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Bikin Hati Tenang', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Instruksi Awal Butuh Penyesuaian', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_4_1', + patientName: 'Pasien Terverifikasi (C. P.)', + sentiment: ReviewSentiment.positive, + label: 'Menyejukkan Hati', + description: 'Sesi bareng Mbak Nathania beneran bikin tenang, teknik pernapasan somatiknya sangat manjur saat cemas melanda di tengah keramaian.', + rating: 5.0, + timeAgo: '2 hari lalu', + ), + PatientReviewItem( + id: 'rev_4_2', + patientName: 'Pasien Terverifikasi (I. W.)', + sentiment: ReviewSentiment.positive, + label: 'Latihan Pernapasan Efektif', + description: 'Diajarkan metode grounding 5-4-3-2-1 yang disesuaikan dengan kondisi saya. Suara dan pembawaannya sangat menenangkan batin.', + rating: 5.0, + timeAgo: '5 hari lalu', + ), + PatientReviewItem( + id: 'rev_4_3', + patientName: 'Pasien Terverifikasi (J. K.)', + sentiment: ReviewSentiment.negative, + label: 'Butuh Pembiasaan Mandiri', + description: 'Teknik mindfulness-nya bagus, tapi butuh disiplin pribadi yang tinggi di luar sesi agar efek tenangnya tidak cepat luntur.', + rating: 4.2, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_4_4', + patientName: 'Pasien Terverifikasi (S. N.)', + sentiment: ReviewSentiment.positive, + label: 'Bikin Hati Tenang', + description: 'Pendekatan penerimaan diri secara utuh membuka sudut pandang baru bahwa tidak apa-apa jika sesekali merasa lelah dan tidak sempurna.', + rating: 5.0, + timeAgo: '2 minggu lalu', + ), + PatientReviewItem( + id: 'rev_4_5', + patientName: 'Pasien Terverifikasi (G. B.)', + sentiment: ReviewSentiment.positive, + label: 'Inner Child Healing Terarah', + description: 'Sesi penyembuhan inner child sangat mendalam. Rasanya seperti berdamai kembali dengan masa lalu dengan penuh kelembutan.', + rating: 4.7, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Sesi bareng Mbak Nathania beneran bikin tenang, teknik pernapasan somatiknya sangat manjur saat cemas melanda.', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 168, + totalSessionsCompleted: 295, + satisfactionRate: 98.1, + positiveReviewRate: 96.5, + recoveryRate: 93.0, + avgRecoverySessions: 3.4, + recoveryDescription: '93% pasien merasakan peningkatan kestabilan emosi & regulasi stres somatik', + ), + certifications: [ + 'SIPP: 1994-08-22-2023 (HIMPSI)', + 'Magister Profesi Psikologi (S2)', + 'Praktisi Psikolog Mandiri / Freelance Terdaftar', + 'Sertifikasi Mindfulness-Based Stress Reduction (MBSR)', + ], + coinsPerSession: 35, + durationMinutes: 50, + availableOnline: true, + clinicAffiliation: 'Freelance', + bio: 'Membimbing individu menemukan kedamaian batin melalui latihan pernapasan, relaksasi somatik, dan penerimaan diri secara utuh.', + ), + const PsychologistModel( + id: 'psy_5', + name: 'Bayu Aditya, S.Psi.', + title: 'Relawan Konselor Sebaya (Peer Counselor)', + registrationNumber: 'Sertifikasi Konselor Sebaya Jiwa Sehat 2023', + avatarUrl: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&auto=format&fit=crop&q=80', + rating: 4.85, + reviewCount: 52, + experienceYears: 2, + practitionerType: PractitionerType.volunteer, + hasPsychologistCertificate: false, + isVolunteerRole: true, + specialties: ['Teman Curhat Sebaya', 'Stres Kuliah & Skripsi', 'Quarter-Life Struggle', 'Kesepian'], + patientReviewTags: [ + 'Teman Cerita yang Ramah', + 'Tanpa Menghakimi', + 'Bukan Jalur Klinis Berat', + 'Sangat Relatable', + ], + reviewTags: [ + PatientReviewTag(label: 'Teman Cerita yang Ramah', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Tanpa Menghakimi', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Bukan Jalur Klinis Berat', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Sangat Relatable', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Slot Konseling Cepat Habis', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_5_1', + patientName: 'Pasien Terverifikasi (M. R.)', + sentiment: ReviewSentiment.positive, + label: 'Teman Cerita yang Ramah', + description: 'Kak Bayu berasa kayak teman curhat yang bener-bener dengerin tanpa nge-judge sama sekali. Sangat membantu saat lagi buntu skripsi!', + rating: 5.0, + timeAgo: '1 hari lalu', + ), + PatientReviewItem( + id: 'rev_5_2', + patientName: 'Pasien Terverifikasi (D. F.)', + sentiment: ReviewSentiment.positive, + label: 'Tanpa Menghakimi', + description: 'Senang banget bisa cerita ke Kak Bayu, bahasanya santai tapi tetap berbobot dan menenangkan saat overthinking masa depan.', + rating: 5.0, + timeAgo: '3 hari lalu', + ), + PatientReviewItem( + id: 'rev_5_3', + patientName: 'Pasien Terverifikasi (L. A.)', + sentiment: ReviewSentiment.negative, + label: 'Batas Ruang Lingkup Sebaya', + description: 'Sebagai konselor sebaya sangat bersahabat, tapi jika memiliki trauma klinis kompleks memang disarankan dirujuk ke psikolog berlisensi.', + rating: 4.1, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_5_4', + patientName: 'Pasien Terverifikasi (P. O.)', + sentiment: ReviewSentiment.positive, + label: 'Sangat Relatable', + description: 'Paham banget keresahan anak muda fresh graduate dan quarter-life struggle. Mengobrol 45 menit rasanya sangat melegakan.', + rating: 5.0, + timeAgo: '2 minggu lalu', + ), + PatientReviewItem( + id: 'rev_5_5', + patientName: 'Pasien Terverifikasi (W. K.)', + sentiment: ReviewSentiment.positive, + label: 'Respon Cepat & Tulus', + description: 'Layanan gratis tapi dedikasinya luar biasa tinggi. Saya merasa tidak sendirian lagi dalam perjuangan mental ini.', + rating: 4.8, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Kak Bayu berasa kayak teman curhat yang bener-bener dengerin tanpa nge-judge sama sekali. Sangat membantu saat lagi buntu!', + 'Senang banget bisa cerita ke Kak Bayu, bahasanya santai tapi tetap berbobot dan menenangkan.', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 195, + totalSessionsCompleted: 270, + satisfactionRate: 97.5, + positiveReviewRate: 94.8, + recoveryRate: 91.2, + avgRecoverySessions: 2.8, + recoveryDescription: '91.2% teman sebaya merasa bebannya terurai & menemukan jalan keluar stres', + ), + certifications: [ + 'Sertifikat Psychological First Aid (PFA) - Kemenkes RI', + 'Pelatihan Konseling Sebaya & Peer Support (HIMPSI)', + 'Fasilitator Komunitas Safe Space Indonesia', + ], + coinsPerSession: 0, + durationMinutes: 45, + availableOnline: true, + clinicAffiliation: null, + bio: 'Relawan konselor sebaya terlatih di bidang Psychological First Aid (PFA). Siap menjadi ruang dengar yang aman dan tulus untuk teman sebaya yang sedang berjuang.', + ), + const PsychologistModel( + id: 'psy_6', + name: 'Nadira Putri, S.Sos.', + title: 'Relawan Pendamping Krisis Emosional', + registrationNumber: 'Sertifikasi Dukungan Psikososial Awal (PFA) 2024', + avatarUrl: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400&auto=format&fit=crop&q=80', + rating: 4.93, + reviewCount: 41, + experienceYears: 3, + practitionerType: PractitionerType.volunteer, + hasPsychologistCertificate: false, + isVolunteerRole: true, + specialties: ['Dukungan Emosional', 'Ruang Dengar Hangat', 'Validasi Perasaan', 'Pencegahan Krisis'], + patientReviewTags: [ + 'Sangat Tulus & Peduli', + 'Menenangkan Saat Kalut', + 'Waktu Konseling Terbatas', + 'Selalu Ada untuk Mendengar', + ], + reviewTags: [ + PatientReviewTag(label: 'Sangat Tulus & Peduli', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Menenangkan Saat Kalut', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Waktu Konseling Terbatas', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Selalu Ada untuk Mendengar', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Tersedia di Jam Malam Tertentu', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_6_1', + patientName: 'Pasien Terverifikasi (S. M.)', + sentiment: ReviewSentiment.positive, + label: 'Sangat Tulus & Peduli', + description: 'Di saat lagi ngerasa sendirian banget dan mau nangis di kamar, Kak Nadira nemenin aku dengan penuh kasih sampai tenang kembali.', + rating: 5.0, + timeAgo: '1 hari lalu', + ), + PatientReviewItem( + id: 'rev_6_2', + patientName: 'Pasien Terverifikasi (H. A.)', + sentiment: ReviewSentiment.positive, + label: 'Menenangkan Saat Kalut', + description: 'Validasi emosi yang diberikan Kak Nadira membuat saya berhenti menyalahkan diri sendiri. Ruang dengar yang benar-benar tanpa syarat.', + rating: 5.0, + timeAgo: '4 hari lalu', + ), + PatientReviewItem( + id: 'rev_6_3', + patientName: 'Pasien Terverifikasi (T. R.)', + sentiment: ReviewSentiment.negative, + label: 'Durasi Terasa Singkat', + description: 'Pendampingannya sangat menghibur hati, hanya saja karena keterbatasan jadwal relawan, sesi harus selesai dalam 45 menit.', + rating: 4.3, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_6_4', + patientName: 'Pasien Terverifikasi (F. K.)', + sentiment: ReviewSentiment.positive, + label: 'Selalu Ada untuk Mendengar', + description: 'Sangat sigap dan empatik. Mengetahui ada seseorang yang peduli secara tulus di masa krisis adalah penyelamat hidup.', + rating: 5.0, + timeAgo: '3 minggu lalu', + ), + PatientReviewItem( + id: 'rev_6_5', + patientName: 'Pasien Terverifikasi (N. L.)', + sentiment: ReviewSentiment.positive, + label: 'Bikin Adem & Reda', + description: 'Teknik stabilisasi awal yang diajarkan sangat menolong saya melewati malam-malam cemas yang berat.', + rating: 4.9, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Di saat lagi ngerasa sendirian banget dan mau nangis, Kak Nadira nemenin aku sampai tenang kembali. Terima kasih banyak!', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 172, + totalSessionsCompleted: 240, + satisfactionRate: 98.5, + positiveReviewRate: 96.0, + recoveryRate: 94.0, + avgRecoverySessions: 2.6, + recoveryDescription: '94% penerima manfaat berhasil melewati fase krisis emosional awal dengan stabil', + ), + certifications: [ + 'Sertifikat Psychological First Aid (PFA) Terakreditasi', + 'Pelatihan Pendampingan Krisis & Dukungan Psikososial', + 'Relawan Tanggap Darurat Kesehatan Jiwa', + ], + coinsPerSession: 0, + durationMinutes: 45, + availableOnline: true, + clinicAffiliation: null, + bio: 'Memiliki komitmen penuh sebagai jembatan empati bagi siapa pun yang membutuhkan telinga untuk mendengar tanpa syarat di saat-saat paling sulit.', + ), + ]; + + final List<ClinicModel> _clinics = [ + const ClinicModel( + id: 'clinic_1', + name: 'Klinik Utama Sahabat Jiwa', + address: 'Jl. Tebet Barat Raya No. 42, Tebet, Jakarta Selatan', + city: 'Jakarta Selatan', + distanceKm: 2.3, + photoUrl: 'https://images.unsplash.com/photo-1519494026892-80bbd2d6fd0d?w=400&auto=format&fit=crop&q=80', + phone: '021-83701234', + facilities: ['Ruang Konseling Privat', 'Farmasi Jiwa', 'Ruang Meditasi', 'Parkir Luas'], + rating: 4.9, + ), + const ClinicModel( + id: 'clinic_2', + name: 'RS Jiwa & Emosi Harmoni', + address: 'Jl. Dr. Saharjo No. 108, Setiabudi, Jakarta Selatan', + city: 'Jakarta Selatan', + distanceKm: 4.1, + photoUrl: 'https://images.unsplash.com/photo-1586773860418-d37222d8fce3?w=400&auto=format&fit=crop&q=80', + phone: '021-82909988', + facilities: ['UGD Psikiatri 24 Jam', 'Laboratorium Tes', 'Konseling Pasangan', 'Ambulans'], + rating: 4.8, + ), + const ClinicModel( + id: 'clinic_3', + name: 'Pusat Konseling Bina Jiwa Bandung', + address: 'Jl. Cihampelas No. 156, Coblong, Bandung', + city: 'Bandung', + distanceKm: 128.0, + photoUrl: 'https://images.unsplash.com/photo-1629909613654-28e377c37b09?w=400&auto=format&fit=crop&q=80', + phone: '022-2031122', + facilities: ['Konseling Komunitas', 'Workshop Art Therapy', 'Taman Terbuka'], + rating: 4.7, + ), + ]; + + late final List<ConsultationSessionModel> _sessions = [ + ConsultationSessionModel( + id: 'sess_1', + psychologist: _psychologists[0], // Amanda Zahra + sessionDateTime: DateTime.now().add(const Duration(minutes: 5)), + status: ConsultationStatus.upcoming, + type: ConsultationType.onlineVideo, + meetingUrl: 'https://meet.eva-care.ai/room-amanda-zahra', + notes: 'Evaluasi intensitas kecemasan dan overthinking terkait evaluasi kinerja tahunan di kantor.', + sessionNumber: 2, + moodBefore: 'Cemas Tinggi (Skala 8/10)', + adviceList: const [ + 'Lakukan relaksasi grounding 5-4-3-2-1 saat merasa kewalahan.', + 'Gunakan format restrukturisasi kognitif di Eva Diary.', + ], + actionItems: const [ + 'Tulis 3 bukti objektif pencapaian kerja sebelum sesi berikutnya.', + ], + ), + ConsultationSessionModel( + id: 'sess_2', + psychologist: _psychologists[1], // Dimas Wicaksono + sessionDateTime: DateTime.now().add(const Duration(days: 2, hours: 3)), + status: ConsultationStatus.upcoming, + type: ConsultationType.onlineVideo, + meetingUrl: 'https://meet.eva-care.ai/room-dimas-wicaksono', + notes: 'Strategi mengatasi burnout kerja dan menetapkan batasan (boundary setting) dengan atasan.', + sessionNumber: 1, + moodBefore: 'Lelah Mental & Jenuh (Skala 7/10)', + ), + ConsultationSessionModel( + id: 'sess_offline_1', + psychologist: _psychologists[2], // Rian Pratama + sessionDateTime: DateTime.now().add(const Duration(days: 1, hours: 2)), + status: ConsultationStatus.upcoming, + type: ConsultationType.offlineClinic, + locationName: 'Klinik Psikologi Sahabat Jiwa Jakarta Barat', + notes: 'Konsultasi tatap muka langsung di klinik untuk pemulihan trauma masa lalu dan psikosomatis.', + sessionNumber: 1, + moodBefore: 'Overthinking & Kelelahan Emosional (Skala 7/10)', + ), + ConsultationSessionModel( + id: 'sess_3', + psychologist: _psychologists[0], // Amanda Zahra + sessionDateTime: DateTime.now().subtract(const Duration(days: 7, hours: 2)), + status: ConsultationStatus.completed, + type: ConsultationType.onlineVideo, + meetingUrl: 'https://meet.eva-care.ai/room-amanda-prev-1', + notes: 'Keluhan awal: Sering sesak napas, gelisah malam hari, dan panik saat deadline kerja menumpuk.', + diagnosisSummary: 'Kecemasan situasional dengan hiperventilasi psikosomatis ringan.', + sessionSummary: 'Mengidentifikasi lingkaran setan overthinking malam hari dan membimbing teknik somatic grounding.', + sessionNumber: 1, + durationMinutes: 50, + moodBefore: 'Sangat Cemas & Panik (Skala 9/10)', + moodAfter: 'Lebih Tenang & Terkendali (Skala 4/10)', + adviceList: const [ + 'Praktikkan teknik relaksasi napas 4-7-8 setiap malam 15 menit sebelum waktu tidur.', + 'Batasi konsumsi berita dan email pekerjaan setelah pukul 19:30 WIB.', + 'Gunakan fitur Jurnal Refleksi Eva untuk meluapkan unek-unek sebelum memejamkan mata.', + ], + actionItems: const [ + 'Lakukan grounding 5-4-3-2-1 begitu merasakan tanda-tanda awal hiperventilasi.', + 'Catat 3 hal yang berhasil dikendalikan hari ini di diary.', + ], + ), + ConsultationSessionModel( + id: 'sess_4', + psychologist: _psychologists[1], // Dimas Wicaksono + sessionDateTime: DateTime.now().subtract(const Duration(days: 16)), + status: ConsultationStatus.completed, + type: ConsultationType.onlineVideo, + meetingUrl: 'https://meet.eva-care.ai/room-dimas-prev', + notes: 'Keluhan awal: Sindrom burnout parah akibat lembur terus-menerus dan kesulitan menolak pekerjaan tambahan.', + diagnosisSummary: 'Exhaustion phase burnout dengan distorsi rasa bersalah tidak rasional.', + sessionSummary: 'Bedah pemicu workaholism dan latihan komunikasi asertif dalam menolak tugas di luar job desk.', + sessionNumber: 1, + durationMinutes: 50, + moodBefore: 'Burnout Berat & Frustrasi (Skala 8/10)', + moodAfter: 'Merasa Berdaya & Sadar Batasan Diri (Skala 4/10)', + adviceList: const [ + 'Gunakan teknik asertif "Broken Record": tegaskan kapasitas kerja dengan tenang tanpa merasa bersalah berlebihan.', + 'Jadwalkan "Digital Detox" minimal 4 jam setiap hari Minggu.', + 'Fokus pada 3 prioritas utama harian daripada memaksakan to-do list panjang tak realistis.', + ], + actionItems: const [ + 'Buat pembagian waktu kerja tegas: jam kerja selesai pukul 18:00 WIB.', + 'Berlatih mengatakan tidak pada satu permintaan non-esensial minggu ini.', + ], + ), + ConsultationSessionModel( + id: 'sess_5', + psychologist: _psychologists[2], // Rian Pratama + sessionDateTime: DateTime.now().subtract(const Duration(days: 28)), + status: ConsultationStatus.completed, + type: ConsultationType.onlineVideo, + meetingUrl: 'https://meet.eva-care.ai/room-rian-prev', + notes: 'Keluhan awal: Konflik berulang dengan pasangan seputar komunikasi dan perasaan tidak didengar.', + diagnosisSummary: 'Pola komunikasi defensif dan disregulasi emosi relasional.', + sessionSummary: 'Mengeksplorasi pemicu luka masa lalu yang terproyeksikan ke pasangan dan latihan active listening.', + sessionNumber: 1, + durationMinutes: 50, + moodBefore: 'Marah & Putus Asa (Skala 8/10)', + moodAfter: 'Hati Hangat & Memahami Pola Pasangan (Skala 4/10)', + adviceList: const [ + 'Gunakan format "I-statement" saat diskusi: "Aku merasa sedih ketika..." bukan "Kamu selalu...".', + 'Ambil jeda time-out 15 menit jika pembicaraan mulai meninggi sebelum diselesaikan.', + 'Hindari asumsi pikiran pasangan (mind-reading), tanyakan klarifikasi langsung.', + ], + actionItems: const [ + 'Latih mindful pause 3 detik sebelum menjawab saat sedang emosional.', + 'Agendakan sesi obrolan apresiasi santai berdua tanpa gadget.', + ], + ), + ]; + + @override + Future<List<PsychologistModel>> getPsychologists({String? category, String? query}) async { + await Future.delayed(const Duration(milliseconds: 300)); + var result = List<PsychologistModel>.from(_psychologists); + + if (category != null && category.isNotEmpty && category != 'all') { + if (category == 'professional') { + result = result.where((p) => p.isProfessional).toList(); + } else if (category == 'freelance') { + result = result.where((p) => p.isFreelance).toList(); + } else if (category == 'volunteer' || category == 'relawan') { + result = result.where((p) => p.isVolunteer || p.practitionerType == PractitionerType.volunteer).toList(); + } else if (category == 'psychologist' || category == 'psikolog') { + result = result.where((p) => p.hasPsychologistCertificate || p.isProfessional || p.isFreelance).toList(); + } + } + + if (query != null && query.trim().isNotEmpty) { + final q = query.toLowerCase().trim(); + result = result.where((p) { + final matchesName = p.name.toLowerCase().contains(q); + final matchesSpecialty = p.specialties.any((s) => s.toLowerCase().contains(q)); + final matchesReviewTag = p.patientReviewTags.any((t) => t.toLowerCase().contains(q)); + final matchesBadge = p.badgeHeaderTitle.toLowerCase().contains(q); + final matchesClinic = p.clinicAffiliation?.toLowerCase().contains(q) ?? false; + return matchesName || matchesSpecialty || matchesReviewTag || matchesBadge || matchesClinic; + }).toList(); + } + + return result; + } + + @override + Future<List<ClinicModel>> getClinics({String? query}) async { + await Future.delayed(const Duration(milliseconds: 300)); + var result = List<ClinicModel>.from(_clinics); + + if (query != null && query.trim().isNotEmpty) { + final q = query.toLowerCase().trim(); + result = result.where((c) { + final matchesName = c.name.toLowerCase().contains(q); + final matchesCity = c.city.toLowerCase().contains(q); + final matchesFacility = c.facilities.any((f) => f.toLowerCase().contains(q)); + return matchesName || matchesCity || matchesFacility; + }).toList(); + } + + return result; + } + + @override + Future<List<ConsultationSessionModel>> getConsultationSessions() async { + await Future.delayed(const Duration(milliseconds: 200)); + return List<ConsultationSessionModel>.from(_sessions); + } + + @override + Future<ConsultationSessionModel> bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) async { + await Future.delayed(const Duration(milliseconds: 400)); + final targetPsy = _psychologists.firstWhere( + (p) => p.id == psychologistId, + orElse: () => _psychologists.first, + ); + + final newSession = ConsultationSessionModel( + id: 'sess_${DateTime.now().millisecondsSinceEpoch}', + psychologist: targetPsy, + sessionDateTime: preferredDateTime, + status: ConsultationStatus.upcoming, + type: sessionType, + meetingUrl: sessionType == ConsultationType.onlineVideo + ? 'https://meet.google.com/eva-consult-${DateTime.now().millisecondsSinceEpoch}' + : null, + locationName: sessionType == ConsultationType.offlineClinic + ? (targetPsy.clinicAffiliation ?? 'Klinik Mitra Eva') + : null, + notes: complaints, + ); + + _sessions.insert(0, newSession); + return newSession; + } + + @override + Future<CommunityStatusModel> getCommunityStatus() async { + await Future.delayed(const Duration(milliseconds: 200)); + return _communityStatus; + } + + @override + Future<CommunityStatusModel> joinCommunityWithVipTrial() async { + await Future.delayed(const Duration(milliseconds: 400)); + _communityStatus = _communityStatus.copyWith( + isMember: true, + isVipActive: true, + vipRemainingDays: 30, + ); + return _communityStatus; + } +} diff --git a/apps/eva_mobile/lib/features/care/data/datasources/remote_care_datasource.dart b/apps/eva_mobile/lib/features/care/data/datasources/remote_care_datasource.dart new file mode 100644 index 0000000..754e0c2 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/datasources/remote_care_datasource.dart @@ -0,0 +1,90 @@ +import '../../../../core/network/api_client.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../models/psychologist_model.dart'; +import '../models/clinic_model.dart'; +import '../models/community_status_model.dart'; +import '../models/consultation_session_model.dart'; +import 'care_datasource.dart'; + +class RemoteCareDataSource implements CareDataSource { + final ApiClient _apiClient; + + RemoteCareDataSource({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + @override + Future<List<PsychologistModel>> getPsychologists({String? category, String? query}) async { + final queryParams = <String, String>{}; + if (category != null && category.isNotEmpty) queryParams['category'] = category; + if (query != null && query.isNotEmpty) queryParams['q'] = query; + + final response = await _apiClient.get( + '/care/psychologists', + queryParams: queryParams.isNotEmpty ? queryParams : null, + ); + + final list = response as List? ?? []; + return list.map((e) => PsychologistModel.fromJson(e as Map<String, dynamic>)).toList(); + } + + @override + Future<List<ClinicModel>> getClinics({String? query}) async { + final queryParams = <String, String>{}; + if (query != null && query.isNotEmpty) queryParams['q'] = query; + + final response = await _apiClient.get( + '/care/clinics', + queryParams: queryParams.isNotEmpty ? queryParams : null, + ); + + final list = response as List? ?? []; + return list.map((e) => ClinicModel.fromJson(e as Map<String, dynamic>)).toList(); + } + + @override + Future<CommunityStatusModel> getCommunityStatus() async { + final response = await _apiClient.get('/community/status'); + if (response is Map<String, dynamic>) { + return CommunityStatusModel.fromJson(response); + } + return const CommunityStatusModel(isMember: false, isVipActive: false); + } + + @override + Future<CommunityStatusModel> joinCommunityWithVipTrial() async { + final response = await _apiClient.post('/community/join'); + if (response is Map<String, dynamic>) { + return CommunityStatusModel.fromJson(response); + } + return const CommunityStatusModel(isMember: true, isVipActive: true, vipRemainingDays: 30); + } + + @override + Future<List<ConsultationSessionModel>> getConsultationSessions() async { + final response = await _apiClient.get('/care/sessions'); + final list = response as List? ?? []; + return list.map((e) => ConsultationSessionModel.fromJson(e as Map<String, dynamic>)).toList(); + } + + @override + Future<ConsultationSessionModel> bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) async { + final response = await _apiClient.post( + '/care/book', + body: { + 'psychologist_id': psychologistId, + 'preferred_datetime': preferredDateTime.toIso8601String(), + 'session_type': sessionType.name, + 'complaints': complaints, + }, + ); + + if (response is Map<String, dynamic>) { + return ConsultationSessionModel.fromJson(response); + } + throw Exception('Failed to book consultation'); + } +} diff --git a/apps/eva_mobile/lib/features/care/data/models/clinic_model.dart b/apps/eva_mobile/lib/features/care/data/models/clinic_model.dart new file mode 100644 index 0000000..628d427 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/models/clinic_model.dart @@ -0,0 +1,46 @@ +import '../../domain/entities/clinic_entity.dart'; + +class ClinicModel extends ClinicEntity { + const ClinicModel({ + required super.id, + required super.name, + required super.address, + required super.city, + required super.distanceKm, + required super.photoUrl, + required super.phone, + required super.facilities, + super.rating = 4.8, + }); + + factory ClinicModel.fromJson(Map<String, dynamic> json) { + return ClinicModel( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + address: json['address'] as String? ?? '', + city: json['city'] as String? ?? '', + distanceKm: (json['distance_km'] as num?)?.toDouble() ?? 1.0, + photoUrl: json['photo_url'] as String? ?? '', + phone: json['phone'] as String? ?? '', + facilities: (json['facilities'] as List<dynamic>?) + ?.map((e) => e.toString()) + .toList() ?? + [], + rating: (json['rating'] as num?)?.toDouble() ?? 4.8, + ); + } + + Map<String, dynamic> toJson() { + return { + 'id': id, + 'name': name, + 'address': address, + 'city': city, + 'distance_km': distanceKm, + 'photo_url': photoUrl, + 'phone': phone, + 'facilities': facilities, + 'rating': rating, + }; + } +} diff --git a/apps/eva_mobile/lib/features/care/data/models/community_status_model.dart b/apps/eva_mobile/lib/features/care/data/models/community_status_model.dart new file mode 100644 index 0000000..f3b4d06 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/models/community_status_model.dart @@ -0,0 +1,60 @@ +import '../../domain/entities/community_status_entity.dart'; + +class CommunityStatusModel extends CommunityStatusEntity { + const CommunityStatusModel({ + required super.isMember, + required super.isVipActive, + super.vipRemainingDays = 0, + super.joinedAt, + super.serverName = 'Eva Safe Space ID', + super.inviteUrl = 'https://discord.gg/evasafespace', + super.memberCount = 1420, + }); + + factory CommunityStatusModel.fromJson(Map<String, dynamic> json) { + return CommunityStatusModel( + isMember: json['is_member'] as bool? ?? false, + isVipActive: json['is_vip_active'] as bool? ?? false, + vipRemainingDays: (json['vip_remaining_days'] as num?)?.toInt() ?? 0, + joinedAt: json['joined_at'] != null + ? DateTime.tryParse(json['joined_at'] as String) + : null, + serverName: json['server_name'] as String? ?? 'Eva Safe Space ID', + inviteUrl: json['invite_url'] as String? ?? 'https://discord.gg/evasafespace', + memberCount: (json['member_count'] as num?)?.toInt() ?? 1420, + ); + } + + @override + CommunityStatusModel copyWith({ + bool? isMember, + bool? isVipActive, + int? vipRemainingDays, + DateTime? joinedAt, + String? serverName, + String? inviteUrl, + int? memberCount, + }) { + return CommunityStatusModel( + isMember: isMember ?? this.isMember, + isVipActive: isVipActive ?? this.isVipActive, + vipRemainingDays: vipRemainingDays ?? this.vipRemainingDays, + joinedAt: joinedAt ?? this.joinedAt, + serverName: serverName ?? this.serverName, + inviteUrl: inviteUrl ?? this.inviteUrl, + memberCount: memberCount ?? this.memberCount, + ); + } + + Map<String, dynamic> toJson() { + return { + 'is_member': isMember, + 'is_vip_active': isVipActive, + 'vip_remaining_days': vipRemainingDays, + 'joined_at': joinedAt?.toIso8601String(), + 'server_name': serverName, + 'invite_url': inviteUrl, + 'member_count': memberCount, + }; + } +} diff --git a/apps/eva_mobile/lib/features/care/data/models/consultation_session_model.dart b/apps/eva_mobile/lib/features/care/data/models/consultation_session_model.dart new file mode 100644 index 0000000..f952d18 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/models/consultation_session_model.dart @@ -0,0 +1,95 @@ +import '../../domain/entities/consultation_session_entity.dart'; +import 'psychologist_model.dart'; + +class ConsultationSessionModel extends ConsultationSessionEntity { + const ConsultationSessionModel({ + required super.id, + required super.psychologist, + required super.sessionDateTime, + required super.status, + super.type = ConsultationType.onlineVideo, + super.meetingUrl, + super.locationName, + super.notes, + super.diagnosisSummary, + super.sessionNumber = 1, + super.adviceList = const [], + super.actionItems = const [], + super.moodBefore, + super.moodAfter, + super.durationMinutes = 50, + super.sessionSummary, + }); + + factory ConsultationSessionModel.fromJson(Map<String, dynamic> json) { + return ConsultationSessionModel( + id: json['id'] as String? ?? '', + psychologist: PsychologistModel.fromJson( + json['psychologist'] as Map<String, dynamic>? ?? {}, + ), + sessionDateTime: DateTime.tryParse(json['session_datetime'] as String? ?? '') ?? + DateTime.now(), + status: _parseStatus(json['status'] as String?), + type: _parseType(json['type'] as String?), + meetingUrl: json['meeting_url'] as String?, + locationName: json['location_name'] as String?, + notes: json['notes'] as String?, + diagnosisSummary: json['diagnosis_summary'] as String?, + sessionNumber: (json['session_number'] as num?)?.toInt() ?? 1, + adviceList: (json['advice_list'] as List<dynamic>?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + actionItems: (json['action_items'] as List<dynamic>?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + moodBefore: json['mood_before'] as String?, + moodAfter: json['mood_after'] as String?, + durationMinutes: (json['duration_minutes'] as num?)?.toInt() ?? 50, + sessionSummary: json['session_summary'] as String?, + ); + } + + static ConsultationStatus _parseStatus(String? status) { + switch (status?.toLowerCase()) { + case 'completed': + return ConsultationStatus.completed; + case 'cancelled': + return ConsultationStatus.cancelled; + default: + return ConsultationStatus.upcoming; + } + } + + static ConsultationType _parseType(String? type) { + switch (type?.toLowerCase()) { + case 'offline_clinic': + case 'offline': + return ConsultationType.offlineClinic; + default: + return ConsultationType.onlineVideo; + } + } + + Map<String, dynamic> toJson() { + return { + 'id': id, + 'psychologist': (psychologist as PsychologistModel).toJson(), + 'session_datetime': sessionDateTime.toIso8601String(), + 'status': status.name, + 'type': type.name, + 'meeting_url': meetingUrl, + 'location_name': locationName, + 'notes': notes, + 'diagnosis_summary': diagnosisSummary, + 'session_number': sessionNumber, + 'advice_list': adviceList, + 'action_items': actionItems, + 'mood_before': moodBefore, + 'mood_after': moodAfter, + 'duration_minutes': durationMinutes, + 'session_summary': sessionSummary, + }; + } +} diff --git a/apps/eva_mobile/lib/features/care/data/models/psychologist_model.dart b/apps/eva_mobile/lib/features/care/data/models/psychologist_model.dart new file mode 100644 index 0000000..0af35e3 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/models/psychologist_model.dart @@ -0,0 +1,162 @@ +import '../../domain/entities/psychologist_entity.dart'; + +class PsychologistModel extends PsychologistEntity { + const PsychologistModel({ + required super.id, + required super.name, + required super.title, + required super.registrationNumber, + required super.avatarUrl, + required super.rating, + required super.reviewCount, + required super.experienceYears, + required super.specialties, + super.patientReviewTags = const [], + super.patientReviews = const [], + super.reviewTags = const [], + super.testimonials = const [], + super.patientStats = const PatientStatsEntity(), + super.certifications = const [], + super.certificateItems = const [], + super.coinsPerSession = 50, + super.durationMinutes = 50, + super.availableOnline = true, + super.clinicAffiliation, + required super.bio, + super.practitionerType = PractitionerType.professional, + super.hasPsychologistCertificate = true, + super.isVolunteerRole = false, + }); + + factory PsychologistModel.fromJson(Map<String, dynamic> json) { + PractitionerType parseType(String? typeStr) { + if (typeStr == 'volunteer') return PractitionerType.volunteer; + if (typeStr == 'freelance') return PractitionerType.freelance; + return PractitionerType.professional; + } + + final parsedType = parseType(json['practitioner_type'] as String?); + final hasCert = json['has_psychologist_certificate'] as bool? ?? + (parsedType != PractitionerType.volunteer); + final isVol = json['is_volunteer'] as bool? ?? + (parsedType == PractitionerType.volunteer); + + final rawReviewTags = json['review_tags']; + List<PatientReviewTag> parsedReviewTags = []; + if (rawReviewTags is List) { + parsedReviewTags = rawReviewTags + .map((e) => e is Map<String, dynamic> + ? PatientReviewTag.fromJson(e) + : PatientReviewTag(label: e.toString(), sentiment: ReviewSentiment.positive)) + .toList(); + } else if (json['patient_review_tags'] is List) { + parsedReviewTags = (json['patient_review_tags'] as List<dynamic>) + .map((e) => PatientReviewTag(label: e.toString(), sentiment: ReviewSentiment.positive)) + .toList(); + } + + final rawTestimonials = json['testimonials']; + List<PatientReviewItem> parsedTestimonials = []; + if (rawTestimonials is List) { + parsedTestimonials = rawTestimonials + .map((e) => e is Map<String, dynamic> + ? PatientReviewItem.fromJson(e) + : PatientReviewItem( + id: 'rev_gen', + label: 'Konseling Membantu', + description: e.toString(), + )) + .toList(); + } else if (json['patient_reviews'] is List) { + parsedTestimonials = (json['patient_reviews'] as List<dynamic>) + .asMap() + .entries + .map((entry) => PatientReviewItem( + id: 'rev_${entry.key}', + label: 'Konseling Membantu', + sentiment: ReviewSentiment.positive, + description: entry.value.toString(), + )) + .toList(); + } + + PatientStatsEntity parsedStats = const PatientStatsEntity(); + if (json['patient_stats'] is Map<String, dynamic>) { + parsedStats = PatientStatsEntity.fromJson(json['patient_stats'] as Map<String, dynamic>); + } + + return PsychologistModel( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + title: json['title'] as String? ?? '', + registrationNumber: json['registration_number'] as String? ?? '', + avatarUrl: json['avatar_url'] as String? ?? '', + rating: (json['rating'] as num?)?.toDouble() ?? 5.0, + reviewCount: (json['review_count'] as num?)?.toInt() ?? 0, + experienceYears: (json['experience_years'] as num?)?.toInt() ?? 1, + specialties: (json['specialties'] as List<dynamic>?) + ?.map((e) => e.toString()) + .toList() ?? + [], + patientReviewTags: (json['patient_review_tags'] as List<dynamic>?) + ?.map((e) => e.toString()) + .toList() ?? + parsedReviewTags.map((e) => e.label).toList(), + patientReviews: (json['patient_reviews'] as List<dynamic>?) + ?.map((e) => e.toString()) + .toList() ?? + parsedTestimonials.map((e) => e.description).toList(), + reviewTags: parsedReviewTags, + testimonials: parsedTestimonials, + patientStats: parsedStats, + certifications: (json['certifications'] as List<dynamic>?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + certificateItems: (json['certificate_items'] as List<dynamic>?) + ?.map((e) => CertificateItem.fromJson(e as Map<String, dynamic>)) + .toList() ?? + const [], + coinsPerSession: (json['coins_per_session'] as num?)?.toInt() ?? + (json['fee_per_session'] as num?)?.toInt() ?? + 50, + durationMinutes: (json['duration_minutes'] as num?)?.toInt() ?? 50, + availableOnline: json['available_online'] as bool? ?? true, + clinicAffiliation: json['clinic_affiliation'] as String?, + bio: json['bio'] as String? ?? '', + practitionerType: parsedType, + hasPsychologistCertificate: hasCert, + isVolunteerRole: isVol, + ); + } + + Map<String, dynamic> toJson() { + return { + 'id': id, + 'name': name, + 'title': title, + 'registration_number': registrationNumber, + 'avatar_url': avatarUrl, + 'rating': rating, + 'review_count': reviewCount, + 'experience_years': experienceYears, + 'specialties': specialties, + 'patient_review_tags': patientReviewTags, + 'patient_reviews': patientReviews, + 'review_tags': reviewTags.map((e) => e.toJson()).toList(), + 'testimonials': testimonials.map((e) => e.toJson()).toList(), + 'patient_stats': patientStats.toJson(), + 'certifications': certifications, + 'certificate_items': certificateItems.map((e) => e.toJson()).toList(), + 'coins_per_session': coinsPerSession, + 'fee_per_session': coinsPerSession, + 'duration_minutes': durationMinutes, + 'available_online': availableOnline, + 'clinic_affiliation': clinicAffiliation, + 'bio': bio, + 'practitioner_type': practitionerType.name, + 'has_psychologist_certificate': hasPsychologistCertificate, + 'is_volunteer': isVolunteer, + }; + } +} diff --git a/apps/eva_mobile/lib/features/care/data/repositories/care_repository_impl.dart b/apps/eva_mobile/lib/features/care/data/repositories/care_repository_impl.dart new file mode 100644 index 0000000..6033c06 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/repositories/care_repository_impl.dart @@ -0,0 +1,52 @@ +import '../../domain/repositories/care_repository.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../../domain/entities/clinic_entity.dart'; +import '../../domain/entities/community_status_entity.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../datasources/care_datasource.dart'; + +class CareRepositoryImpl implements CareRepository { + final CareDataSource _dataSource; + + CareRepositoryImpl(this._dataSource); + + @override + Future<List<PsychologistEntity>> getPsychologists({String? category, String? query}) { + return _dataSource.getPsychologists(category: category, query: query); + } + + @override + Future<List<ClinicEntity>> getClinics({String? query}) { + return _dataSource.getClinics(query: query); + } + + @override + Future<CommunityStatusEntity> getCommunityStatus() { + return _dataSource.getCommunityStatus(); + } + + @override + Future<CommunityStatusEntity> joinCommunityWithVipTrial() { + return _dataSource.joinCommunityWithVipTrial(); + } + + @override + Future<List<ConsultationSessionEntity>> getConsultationSessions() { + return _dataSource.getConsultationSessions(); + } + + @override + Future<ConsultationSessionEntity> bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) { + return _dataSource.bookConsultation( + psychologistId: psychologistId, + preferredDateTime: preferredDateTime, + sessionType: sessionType, + complaints: complaints, + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/data/repositories/fallback_care_repository.dart b/apps/eva_mobile/lib/features/care/data/repositories/fallback_care_repository.dart new file mode 100644 index 0000000..24990d0 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/repositories/fallback_care_repository.dart @@ -0,0 +1,86 @@ +import '../../domain/repositories/care_repository.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../../domain/entities/clinic_entity.dart'; +import '../../domain/entities/community_status_entity.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackCareRepository implements CareRepository { + final CareRepository _remote; + final CareRepository _mock; + + FallbackCareRepository({ + required CareRepository remote, + required CareRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future<List<PsychologistEntity>> getPsychologists({String? category, String? query}) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getPsychologists(category: category, query: query), + mockCall: () => _mock.getPsychologists(category: category, query: query), + operationName: 'getPsychologists', + ); + } + + @override + Future<List<ClinicEntity>> getClinics({String? query}) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getClinics(query: query), + mockCall: () => _mock.getClinics(query: query), + operationName: 'getClinics', + ); + } + + @override + Future<CommunityStatusEntity> getCommunityStatus() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getCommunityStatus(), + mockCall: () => _mock.getCommunityStatus(), + operationName: 'getCommunityStatus', + ); + } + + @override + Future<CommunityStatusEntity> joinCommunityWithVipTrial() { + return FallbackExecutor.execute( + remoteCall: () => _remote.joinCommunityWithVipTrial(), + mockCall: () => _mock.joinCommunityWithVipTrial(), + operationName: 'joinCommunityWithVipTrial', + ); + } + + @override + Future<List<ConsultationSessionEntity>> getConsultationSessions() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getConsultationSessions(), + mockCall: () => _mock.getConsultationSessions(), + operationName: 'getConsultationSessions', + ); + } + + @override + Future<ConsultationSessionEntity> bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) { + return FallbackExecutor.execute( + remoteCall: () => _remote.bookConsultation( + psychologistId: psychologistId, + preferredDateTime: preferredDateTime, + sessionType: sessionType, + complaints: complaints, + ), + mockCall: () => _mock.bookConsultation( + psychologistId: psychologistId, + preferredDateTime: preferredDateTime, + sessionType: sessionType, + complaints: complaints, + ), + operationName: 'bookConsultation', + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/domain/entities/clinic_entity.dart b/apps/eva_mobile/lib/features/care/domain/entities/clinic_entity.dart new file mode 100644 index 0000000..37b8e5e --- /dev/null +++ b/apps/eva_mobile/lib/features/care/domain/entities/clinic_entity.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class ClinicEntity { + final String id; + final String name; + final String address; + final String city; + final double distanceKm; + final String photoUrl; + final String phone; + final List<String> facilities; + final double rating; + + const ClinicEntity({ + required this.id, + required this.name, + required this.address, + required this.city, + required this.distanceKm, + required this.photoUrl, + required this.phone, + required this.facilities, + this.rating = 4.8, + }); +} diff --git a/apps/eva_mobile/lib/features/care/domain/entities/community_status_entity.dart b/apps/eva_mobile/lib/features/care/domain/entities/community_status_entity.dart new file mode 100644 index 0000000..5086be4 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/domain/entities/community_status_entity.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class CommunityStatusEntity { + final bool isMember; + final bool isVipActive; + final int vipRemainingDays; + final DateTime? joinedAt; + final String serverName; + final String inviteUrl; + final int memberCount; + + const CommunityStatusEntity({ + required this.isMember, + required this.isVipActive, + this.vipRemainingDays = 0, + this.joinedAt, + this.serverName = 'Eva Safe Space ID', + this.inviteUrl = 'https://discord.gg/evasafespace', + this.memberCount = 1420, + }); + + CommunityStatusEntity copyWith({ + bool? isMember, + bool? isVipActive, + int? vipRemainingDays, + DateTime? joinedAt, + String? serverName, + String? inviteUrl, + int? memberCount, + }) { + return CommunityStatusEntity( + isMember: isMember ?? this.isMember, + isVipActive: isVipActive ?? this.isVipActive, + vipRemainingDays: vipRemainingDays ?? this.vipRemainingDays, + joinedAt: joinedAt ?? this.joinedAt, + serverName: serverName ?? this.serverName, + inviteUrl: inviteUrl ?? this.inviteUrl, + memberCount: memberCount ?? this.memberCount, + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/domain/entities/consultation_session_entity.dart b/apps/eva_mobile/lib/features/care/domain/entities/consultation_session_entity.dart new file mode 100644 index 0000000..949f034 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/domain/entities/consultation_session_entity.dart @@ -0,0 +1,96 @@ +import 'package:flutter/foundation.dart'; +import 'psychologist_entity.dart'; + +enum ConsultationStatus { + upcoming, + completed, + cancelled, +} + +enum ConsultationType { + onlineVideo, + offlineClinic, +} + +@immutable +class ConsultationSessionEntity { + final String id; + final PsychologistEntity psychologist; + final DateTime sessionDateTime; + final ConsultationStatus status; + final ConsultationType type; + final String? meetingUrl; + final String? locationName; + final String? notes; + final String? diagnosisSummary; + final int sessionNumber; + final List<String> adviceList; + final List<String> actionItems; + final String? moodBefore; + final String? moodAfter; + final int durationMinutes; + final String? sessionSummary; + + const ConsultationSessionEntity({ + required this.id, + required this.psychologist, + required this.sessionDateTime, + required this.status, + this.type = ConsultationType.onlineVideo, + this.meetingUrl, + this.locationName, + this.notes, + this.diagnosisSummary, + this.sessionNumber = 1, + this.adviceList = const [], + this.actionItems = const [], + this.moodBefore, + this.moodAfter, + this.durationMinutes = 50, + this.sessionSummary, + }); + + bool get isUpcoming => status == ConsultationStatus.upcoming; + bool get isCompleted => status == ConsultationStatus.completed; + bool get isCancelled => status == ConsultationStatus.cancelled; + bool get isOnlineVideo => type == ConsultationType.onlineVideo; + bool get isOfflineClinic => type == ConsultationType.offlineClinic; + + ConsultationSessionEntity copyWith({ + String? id, + PsychologistEntity? psychologist, + DateTime? sessionDateTime, + ConsultationStatus? status, + ConsultationType? type, + String? meetingUrl, + String? locationName, + String? notes, + String? diagnosisSummary, + int? sessionNumber, + List<String>? adviceList, + List<String>? actionItems, + String? moodBefore, + String? moodAfter, + int? durationMinutes, + String? sessionSummary, + }) { + return ConsultationSessionEntity( + id: id ?? this.id, + psychologist: psychologist ?? this.psychologist, + sessionDateTime: sessionDateTime ?? this.sessionDateTime, + status: status ?? this.status, + type: type ?? this.type, + meetingUrl: meetingUrl ?? this.meetingUrl, + locationName: locationName ?? this.locationName, + notes: notes ?? this.notes, + diagnosisSummary: diagnosisSummary ?? this.diagnosisSummary, + sessionNumber: sessionNumber ?? this.sessionNumber, + adviceList: adviceList ?? this.adviceList, + actionItems: actionItems ?? this.actionItems, + moodBefore: moodBefore ?? this.moodBefore, + moodAfter: moodAfter ?? this.moodAfter, + durationMinutes: durationMinutes ?? this.durationMinutes, + sessionSummary: sessionSummary ?? this.sessionSummary, + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart b/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart new file mode 100644 index 0000000..167d32b --- /dev/null +++ b/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart @@ -0,0 +1,319 @@ +import 'package:flutter/foundation.dart'; + +enum PractitionerType { + professional, // Psikolog Profesional Independen + freelance, // Psikolog Freelance + volunteer, // Volunteer / Relawan Konselor Sebaya +} + +enum ReviewSentiment { + positive, + negative, +} + +@immutable +class PatientReviewTag { + final String label; + final ReviewSentiment sentiment; + final DateTime? createdAt; + + const PatientReviewTag({ + required this.label, + this.sentiment = ReviewSentiment.positive, + this.createdAt, + }); + + bool get isPositive => sentiment == ReviewSentiment.positive; + bool get isNegative => sentiment == ReviewSentiment.negative; + + factory PatientReviewTag.fromJson(Map<String, dynamic> json) { + return PatientReviewTag( + label: json['label'] as String? ?? '', + sentiment: (json['sentiment'] as String?) == 'negative' + ? ReviewSentiment.negative + : ReviewSentiment.positive, + createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'] as String) : null, + ); + } + + Map<String, dynamic> toJson() => { + 'label': label, + 'sentiment': sentiment == ReviewSentiment.negative ? 'negative' : 'positive', + if (createdAt != null) 'created_at': createdAt!.toIso8601String(), + }; +} + +@immutable +class PatientReviewItem { + final String id; + final String patientName; + final ReviewSentiment sentiment; + final String label; // ulasan singkat / tag label + final String description; // deskripsi lengkap + final double rating; + final String timeAgo; + final DateTime? date; + + const PatientReviewItem({ + required this.id, + this.patientName = 'Pasien Terverifikasi (Anonim)', + this.sentiment = ReviewSentiment.positive, + required this.label, + required this.description, + this.rating = 5.0, + this.timeAgo = 'Baru saja', + this.date, + }); + + bool get isPositive => sentiment == ReviewSentiment.positive; + bool get isNegative => sentiment == ReviewSentiment.negative; + + factory PatientReviewItem.fromJson(Map<String, dynamic> json) { + return PatientReviewItem( + id: json['id'] as String? ?? '', + patientName: json['patient_name'] as String? ?? 'Pasien Terverifikasi (Anonim)', + sentiment: (json['sentiment'] as String?) == 'negative' + ? ReviewSentiment.negative + : ReviewSentiment.positive, + label: json['label'] as String? ?? '', + description: json['description'] as String? ?? '', + rating: (json['rating'] as num?)?.toDouble() ?? 5.0, + timeAgo: json['time_ago'] as String? ?? 'Baru saja', + date: json['date'] != null ? DateTime.tryParse(json['date'] as String) : null, + ); + } + + Map<String, dynamic> toJson() => { + 'id': id, + 'patient_name': patientName, + 'sentiment': sentiment == ReviewSentiment.negative ? 'negative' : 'positive', + 'label': label, + 'description': description, + 'rating': rating, + 'time_ago': timeAgo, + if (date != null) 'date': date!.toIso8601String(), + }; +} + +@immutable +class PatientStatsEntity { + final int totalPatientsTreated; + final int totalSessionsCompleted; + final double satisfactionRate; // e.g. 98.4% + final double positiveReviewRate; // e.g. 96.0% + final double recoveryRate; // e.g. 93.5% + final double avgRecoverySessions; // e.g. 3.8 sesi + final String recoveryDescription; + + const PatientStatsEntity({ + this.totalPatientsTreated = 180, + this.totalSessionsCompleted = 320, + this.satisfactionRate = 98.0, + this.positiveReviewRate = 96.0, + this.recoveryRate = 93.0, + this.avgRecoverySessions = 3.6, + this.recoveryDescription = 'Melaporkan perbaikan emosi & penurunan kecemasan', + }); + + double get negativeReviewRate => (100.0 - positiveReviewRate).clamp(0.0, 100.0); + + factory PatientStatsEntity.fromJson(Map<String, dynamic> json) { + return PatientStatsEntity( + totalPatientsTreated: (json['total_patients_treated'] as num?)?.toInt() ?? 180, + totalSessionsCompleted: (json['total_sessions_completed'] as num?)?.toInt() ?? 320, + satisfactionRate: (json['satisfaction_rate'] as num?)?.toDouble() ?? 98.0, + positiveReviewRate: (json['positive_review_rate'] as num?)?.toDouble() ?? 96.0, + recoveryRate: (json['recovery_rate'] as num?)?.toDouble() ?? 93.0, + avgRecoverySessions: (json['avg_recovery_sessions'] as num?)?.toDouble() ?? 3.6, + recoveryDescription: json['recovery_description'] as String? ?? + 'Melaporkan perbaikan emosi & penurunan kecemasan', + ); + } + + Map<String, dynamic> toJson() => { + 'total_patients_treated': totalPatientsTreated, + 'total_sessions_completed': totalSessionsCompleted, + 'satisfaction_rate': satisfactionRate, + 'positive_review_rate': positiveReviewRate, + 'recovery_rate': recoveryRate, + 'avg_recovery_sessions': avgRecoverySessions, + 'recovery_description': recoveryDescription, + }; +} + +@immutable +class CertificateItem { + final String title; + final String imageUrl; + + const CertificateItem({ + required this.title, + required this.imageUrl, + }); + + factory CertificateItem.fromJson(Map<String, dynamic> json) { + return CertificateItem( + title: json['title'] as String? ?? '', + imageUrl: json['image_url'] as String? ?? '', + ); + } + + Map<String, dynamic> toJson() => { + 'title': title, + 'image_url': imageUrl, + }; +} + +@immutable +class PsychologistEntity { + final String id; + final String name; + final String title; + final String registrationNumber; // STR or SIPP or Sertifikasi + final String avatarUrl; + final double rating; + final int reviewCount; + final int experienceYears; + final List<String> specialties; + final List<String> patientReviewTags; + final List<String> patientReviews; + final List<PatientReviewTag> reviewTags; + final List<PatientReviewItem> testimonials; + final PatientStatsEntity patientStats; + final List<String> certifications; + final List<CertificateItem> certificateItems; + final int coinsPerSession; // Coin charge per session (0 = Gratis) + final int durationMinutes; + final bool availableOnline; + final String? clinicAffiliation; + final String bio; + final PractitionerType practitionerType; + final bool hasPsychologistCertificate; + final bool isVolunteerRole; + + const PsychologistEntity({ + required this.id, + required this.name, + required this.title, + required this.registrationNumber, + required this.avatarUrl, + required this.rating, + required this.reviewCount, + required this.experienceYears, + required this.specialties, + this.patientReviewTags = const [], + this.patientReviews = const [], + this.reviewTags = const [], + this.testimonials = const [], + this.patientStats = const PatientStatsEntity(), + this.certifications = const [], + this.certificateItems = const [], + this.coinsPerSession = 50, + this.durationMinutes = 50, + this.availableOnline = true, + this.clinicAffiliation, + required this.bio, + this.practitionerType = PractitionerType.professional, + this.hasPsychologistCertificate = true, + this.isVolunteerRole = false, + }); + + /// Effective certifications falling back to registration credentials if empty + List<String> get effectiveCertifications { + if (certifications.isNotEmpty) return certifications; + final list = <String>[]; + if (hasPsychologistCertificate) { + list.add('Surat Izin Praktik Psikologi ($registrationNumber)'); + list.add('Magister Profesi Psikologi Klinis (S2)'); + list.add('Sertifikasi Kode Etik Profesi HIMPSI'); + } + if (isVolunteer) { + list.add('Sertifikat Psychological First Aid (PFA)'); + list.add('Pelatihan Pendampingan Konseling Sebaya'); + } + return list; + } + + /// Effective certificate items with photo URLs and clean titles + List<CertificateItem> get effectiveCertificateItems { + if (certificateItems.isNotEmpty) return certificateItems; + return effectiveCertifications.map((title) { + final lower = title.toLowerCase(); + String url; + if (lower.contains('sipp')) { + url = 'https://images.unsplash.com/photo-1614036417651-efe5912149d8?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('magister') || lower.contains('s2')) { + url = 'https://images.unsplash.com/photo-1659080928170-b9924d616f04?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('cbt') || lower.contains('sfbt') || lower.contains('mbsr')) { + url = 'https://images.unsplash.com/photo-1659080907100-23f0dac0fe27?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('pfa') || lower.contains('first aid')) { + url = 'https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('peer') || lower.contains('sebaya') || lower.contains('safe space')) { + url = 'https://images.unsplash.com/photo-1450133064473-71024230f91b?w=800&auto=format&fit=crop&q=80'; + } else { + url = 'https://images.unsplash.com/photo-1523240795612-9a054b0db644?w=800&auto=format&fit=crop&q=80'; + } + return CertificateItem(title: title, imageUrl: url); + }).toList(); + } + + /// Effective review tags falling back to patientReviewTags if reviewTags is empty + List<PatientReviewTag> get effectiveReviewTags { + if (reviewTags.isNotEmpty) return reviewTags; + return patientReviewTags + .map((tag) => PatientReviewTag(label: tag, sentiment: ReviewSentiment.positive)) + .toList(); + } + + /// Effective testimonials falling back to patientReviews if testimonials is empty + List<PatientReviewItem> get effectiveTestimonials { + if (testimonials.isNotEmpty) return testimonials; + return patientReviews.asMap().entries.map((entry) { + return PatientReviewItem( + id: 'rev_${entry.key}', + patientName: 'Pasien Terverifikasi (Anonim)', + sentiment: ReviewSentiment.positive, + label: 'Konseling Membantu', + description: entry.value, + rating: 5.0, + timeAgo: '${entry.key + 1} minggu lalu', + ); + }).toList(); + } + + /// Backwards compatibility getter + int get feePerSession => coinsPerSession; + + bool get isVolunteer => isVolunteerRole || practitionerType == PractitionerType.volunteer; + bool get isFreelance => practitionerType == PractitionerType.freelance; + bool get isProfessional => practitionerType == PractitionerType.professional; + bool get hasBothBadges => hasPsychologistCertificate && isVolunteer; + + String get badgeHeaderTitle { + if (hasBothBadges) { + return 'Relawan & Profesional'; + } else if (hasPsychologistCertificate) { + return 'Profesional'; + } else if (isVolunteer) { + return 'Relawan'; + } else { + return 'Profesional'; + } + } + + String get typeLabel { + if (hasBothBadges) { + return 'Relawan & Profesional'; + } else if (isVolunteer) { + return 'Relawan'; + } else { + return 'Profesional'; + } + } + + bool get isPsychiatrist => + title.toLowerCase().contains('sp.kj') || + title.toLowerCase().contains('kedokteran jiwa') || + title.toLowerCase().contains('psikiater') || + name.toLowerCase().contains('sp.kj'); +} diff --git a/apps/eva_mobile/lib/features/care/domain/repositories/care_repository.dart b/apps/eva_mobile/lib/features/care/domain/repositories/care_repository.dart new file mode 100644 index 0000000..35a1616 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/domain/repositories/care_repository.dart @@ -0,0 +1,32 @@ +import '../entities/psychologist_entity.dart'; +import '../entities/clinic_entity.dart'; +import '../entities/community_status_entity.dart'; +import '../entities/consultation_session_entity.dart'; + +abstract class CareRepository { + /// Mendapatkan daftar psikolog & psikiater dengan opsi filter kategori dan pencarian nama. + Future<List<PsychologistEntity>> getPsychologists({ + String? category, // 'all', 'psychologist', 'psychiatrist' + String? query, + }); + + /// Mendapatkan daftar klinik rekanan. + Future<List<ClinicEntity>> getClinics({String? query}); + + /// Mendapatkan status keanggotaan komunitas Discord pengguna. + Future<CommunityStatusEntity> getCommunityStatus(); + + /// Memproses pembelian tiket masuk komunitas sekali bayar + 1 bulan trial VIP event. + Future<CommunityStatusEntity> joinCommunityWithVipTrial(); + + /// Mendapatkan riwayat dan jadwal sesi konsultasi pengguna. + Future<List<ConsultationSessionEntity>> getConsultationSessions(); + + /// Melakukan booking sesi konsultasi baru. + Future<ConsultationSessionEntity> bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }); +} diff --git a/apps/eva_mobile/lib/features/care/presentation/providers/care_provider.dart b/apps/eva_mobile/lib/features/care/presentation/providers/care_provider.dart new file mode 100644 index 0000000..535aeb0 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/providers/care_provider.dart @@ -0,0 +1,178 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../config/app_config.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../../domain/entities/clinic_entity.dart'; +import '../../domain/entities/community_status_entity.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../../domain/repositories/care_repository.dart'; +import '../../data/datasources/mock_care_datasource.dart'; +import '../../data/datasources/remote_care_datasource.dart'; +import '../../data/repositories/care_repository_impl.dart'; +import '../../data/repositories/fallback_care_repository.dart'; + +// Persistent in-memory mock instance across provider updates +final _sharedMockCareDataSource = MockCareDataSource(); + +final careRepositoryProvider = Provider<CareRepository>((ref) { + final mockRepo = CareRepositoryImpl(_sharedMockCareDataSource); + final remoteRepo = CareRepositoryImpl(RemoteCareDataSource()); + + if (AppConfig.useMockData) { + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackCareRepository(remote: remoteRepo, mock: mockRepo); + } else { + return remoteRepo; + } +}); + +// Category Filter: 'all', 'psychologist', 'psychiatrist', 'clinic' +final careCategoryFilterProvider = StateProvider<String>((ref) => 'all'); + +// Search Query Filter +final careSearchQueryProvider = StateProvider<String>((ref) => ''); + +// Psychologists & Psychiatrists Provider +final psychologistsListProvider = FutureProvider<List<PsychologistEntity>>((ref) async { + final repo = ref.watch(careRepositoryProvider); + final category = ref.watch(careCategoryFilterProvider); + final query = ref.watch(careSearchQueryProvider); + + // If the filter is explicitly 'clinic', psychologists list can be empty or unchanged + if (category == 'clinic') return []; + + return repo.getPsychologists( + category: category == 'all' ? null : category, + query: query, + ); +}); + +// Clinics Provider +final clinicsListProvider = FutureProvider<List<ClinicEntity>>((ref) async { + final repo = ref.watch(careRepositoryProvider); + final query = ref.watch(careSearchQueryProvider); + return repo.getClinics(query: query); +}); + +// Community Status Notifier (Handles instant upgrade & state sync) +class CommunityStatusNotifier extends StateNotifier<AsyncValue<CommunityStatusEntity>> { + final CareRepository _repo; + + CommunityStatusNotifier(this._repo) : super(const AsyncValue.loading()) { + loadStatus(); + } + + Future<void> loadStatus() async { + state = const AsyncValue.loading(); + try { + final status = await _repo.getCommunityStatus(); + state = AsyncValue.data(status); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future<bool> joinWithVipTrial() async { + try { + final updated = await _repo.joinCommunityWithVipTrial(); + state = AsyncValue.data(updated); + return true; + } catch (e, st) { + state = AsyncValue.error(e, st); + return false; + } + } +} + +final communityStatusProvider = + StateNotifierProvider<CommunityStatusNotifier, AsyncValue<CommunityStatusEntity>>((ref) { + final repo = ref.watch(careRepositoryProvider); + return CommunityStatusNotifier(repo); +}); + +// Consultation Sessions Notifier (Manages upcoming & completed history) +class ConsultationSessionsNotifier + extends StateNotifier<AsyncValue<List<ConsultationSessionEntity>>> { + final CareRepository _repo; + + ConsultationSessionsNotifier(this._repo) : super(const AsyncValue.loading()) { + loadSessions(); + } + + Future<void> loadSessions() async { + state = const AsyncValue.loading(); + try { + final sessions = await _repo.getConsultationSessions(); + state = AsyncValue.data(sessions); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future<bool> bookNewSession({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) async { + try { + final newSession = await _repo.bookConsultation( + psychologistId: psychologistId, + preferredDateTime: preferredDateTime, + sessionType: sessionType, + complaints: complaints, + ); + + final current = state.value ?? []; + state = AsyncValue.data([newSession, ...current]); + return true; + } catch (e) { + return false; + } + } + + Future<void> completeSession( + String sessionId, { + List<String>? adviceList, + List<String>? actionItems, + String? sessionSummary, + String? moodAfter, + }) async { + final current = state.value ?? []; + state = AsyncValue.data( + current.map((s) { + if (s.id == sessionId) { + return s.copyWith( + status: ConsultationStatus.completed, + adviceList: adviceList ?? + (s.adviceList.isNotEmpty + ? s.adviceList + : const [ + 'Lakukan teknik relaksasi napas dalam saat merasa cemas.', + 'Catat perubahan suasana hati di Jurnal Eva.', + 'Jadwalkan sesi follow-up dalam 2 minggu ke depan.', + ]), + actionItems: actionItems ?? + (s.actionItems.isNotEmpty + ? s.actionItems + : const [ + 'Praktikkan latihan mindfulness 5 menit setiap pagi.', + ]), + sessionSummary: sessionSummary ?? + s.sessionSummary ?? + 'Sesi konsultasi tatap muka online selesai dilaksanakan dengan hasil positif.', + moodAfter: moodAfter ?? 'Tenang & Lebih Lega (Skala 4/10)', + ); + } + return s; + }).toList(), + ); + } +} + +final consultationSessionsProvider = StateNotifierProvider< + ConsultationSessionsNotifier, + AsyncValue<List<ConsultationSessionEntity>>>((ref) { + final repo = ref.watch(careRepositoryProvider); + return ConsultationSessionsNotifier(repo); +}); diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/care_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/care_screen.dart new file mode 100644 index 0000000..f961468 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/care_screen.dart @@ -0,0 +1,408 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../config/app_config.dart'; +import '../../../../theme/app_colors.dart'; +import '../../../auth/presentation/screens/profile_screen.dart'; +import '../../../home/presentation/widgets/profile_dropdown_modal.dart'; +import '../providers/care_provider.dart'; +import '../widgets/care_quick_actions.dart'; +import '../widgets/psychologist_card.dart'; +import '../widgets/booking_consultation_sheet.dart'; +import 'psychologist_detail_screen.dart'; + +class CareScreen extends ConsumerStatefulWidget { + final GlobalKey? firstPsychologistCardKey; + + const CareScreen({ + super.key, + this.firstPsychologistCardKey, + }); + + @override + ConsumerState<CareScreen> createState() => CareScreenState(); +} + +class CareScreenState extends ConsumerState<CareScreen> { + final TextEditingController _searchController = TextEditingController(); + final GlobalKey _profileButtonKey = GlobalKey(); + OverlayEntry? _profileOverlayEntry; + + String _userName = 'Pengguna EVA'; + String _userEmail = 'user@eva.ai'; + + @override + void initState() { + super.initState(); + _loadUserInfo(); + } + + @override + void dispose() { + _searchController.dispose(); + _removeProfileOverlay(); + super.dispose(); + } + + Future<void> _loadUserInfo() async { + final name = await AppConfig.getUserName(); + final email = await AppConfig.getUserEmail(); + if (mounted) { + setState(() { + if (name != null && name.isNotEmpty) _userName = name; + if (email != null && email.isNotEmpty) _userEmail = email; + }); + } + } + + String get _userInitials { + final parts = _userName.trim().split(RegExp(r'\s+')); + if (parts.length >= 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } else if (parts.isNotEmpty && parts[0].isNotEmpty) { + return parts[0].substring(0, parts[0].length >= 2 ? 2 : 1).toUpperCase(); + } + return 'EV'; + } + + void _removeProfileOverlay() { + _profileOverlayEntry?.remove(); + _profileOverlayEntry = null; + } + + void _toggleProfileDropdown() { + if (_profileOverlayEntry != null) { + _removeProfileOverlay(); + return; + } + + final renderBox = _profileButtonKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + + _profileOverlayEntry = OverlayEntry( + builder: (context) => Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _removeProfileOverlay, + ), + ), + Positioned( + top: offset.dy + size.height + 8, + right: 16, + child: Material( + color: Colors.transparent, + child: ProfileDropdownModal( + userName: _userName, + userEmail: _userEmail, + onProfileTap: () { + _removeProfileOverlay(); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProfileScreen()), + ); + }, + onEmergencyContactsTap: () { + _removeProfileOverlay(); + Navigator.pushNamed(context, '/emergency_contacts'); + }, + onSettingsTap: () { + _removeProfileOverlay(); + Navigator.pushNamed(context, '/settings'); + }, + onCoinTap: () { + _removeProfileOverlay(); + Navigator.pushNamed(context, '/coin_purchase'); + }, + onLogoutTap: () { + _removeProfileOverlay(); + Navigator.pushReplacementNamed(context, '/login'); + }, + ), + ), + ), + ], + ), + ); + + Overlay.of(context).insert(_profileOverlayEntry!); + } + + @override + Widget build(BuildContext context) { + final activeFilter = ref.watch(careCategoryFilterProvider); + final psychologistsAsync = ref.watch(psychologistsListProvider); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: () async { + ref.invalidate(psychologistsListProvider); + ref.read(consultationSessionsProvider.notifier).loadSessions(); + }, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // 1. Header with Profile Dropdown + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Layanan Konseling', + style: GoogleFonts.plusJakartaSans( + fontSize: 22, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'Konseling psikolog berlisensi & relawan sebaya', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + GestureDetector( + key: _profileButtonKey, + onTap: _toggleProfileDropdown, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primaryContainer, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.12), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Center( + child: Text( + _userInitials, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ), + ), + ], + ), + ), + ), + + // 2. Quick Action Hub (Jadwal, Riwayat, Koin, Panduan) + const SliverToBoxAdapter( + child: CareQuickActions(), + ), + + const SliverToBoxAdapter( + child: SizedBox(height: 10), + ), + + // 3. Search Bar & Filter Chips (Psikolog, Relawan) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Temukan Psikolog & Relawan', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + + // Search TextField + TextField( + controller: _searchController, + onChanged: (val) { + ref.read(careSearchQueryProvider.notifier).state = val; + }, + decoration: InputDecoration( + hintText: 'Cari nama konselor, ulasan pasien, atau isu...', + hintStyle: GoogleFonts.plusJakartaSans( + fontSize: 13, + color: AppColors.textLight, + ), + prefixIcon: const Icon(Icons.search, color: AppColors.primary), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + ref.read(careSearchQueryProvider.notifier).state = ''; + }, + ) + : null, + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + const SizedBox(height: 12), + + // Filter Chips: Semua, Psikolog, Relawan + SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( + children: [ + _buildFilterChip('Semua', 'all', activeFilter), + const SizedBox(width: 8), + _buildFilterChip('Psikolog', 'psychologist', activeFilter), + const SizedBox(width: 8), + _buildFilterChip('Relawan', 'volunteer', activeFilter), + ], + ), + ), + ], + ), + ), + ), + + // 4. Main Directory List of Psychologists / Volunteers + psychologistsAsync.when( + data: (psychologists) { + if (psychologists.isEmpty) { + return _buildEmptyState('Tidak ada psikolog atau relawan yang cocok'); + } + return SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 100), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final psychologist = psychologists[index]; + return Padding( + key: index == 0 ? widget.firstPsychologistCardKey : null, + padding: const EdgeInsets.only(bottom: 14), + child: PsychologistCard( + psychologist: psychologist, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PsychologistDetailScreen( + psychologist: psychologist, + ), + ), + ); + }, + onBookTap: () { + BookingConsultationSheet.show(context, psychologist); + }, + ), + ); + }, + childCount: psychologists.length, + ), + ), + ); + }, + loading: () => SliverToBoxAdapter( + key: widget.firstPsychologistCardKey, + child: const Padding( + padding: EdgeInsets.all(40), + child: Center(child: CircularProgressIndicator(color: AppColors.primary)), + ), + ), + error: (err, _) => SliverToBoxAdapter( + child: Center(child: Text('Gagal memuat konselor: $err')), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildFilterChip(String label, String key, String activeKey) { + final isSelected = activeKey == key; + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (selected) { + ref.read(careCategoryFilterProvider.notifier).state = + selected ? key : 'all'; + }, + selectedColor: AppColors.primary, + checkmarkColor: Colors.white, + backgroundColor: Colors.white, + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isSelected ? AppColors.primary : const Color(0xFFE2E8F0), + ), + ), + ); + } + + Widget _buildEmptyState(String message) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + children: [ + const Icon(Icons.search_off_rounded, size: 48, color: Color(0xFF94A3B8)), + const SizedBox(height: 12), + Text( + message, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/consultation_history_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_history_screen.dart new file mode 100644 index 0000000..8b07364 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_history_screen.dart @@ -0,0 +1,587 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../providers/care_provider.dart'; +import '../widgets/booking_consultation_sheet.dart'; +import 'consultation_session_logs_screen.dart'; +import 'psychologist_detail_screen.dart'; + +/// Screen displaying the history of psychologists hired and comprehensive per-session clinical logs & advice. +class ConsultationHistoryScreen extends ConsumerStatefulWidget { + const ConsultationHistoryScreen({super.key}); + + @override + ConsumerState<ConsultationHistoryScreen> createState() => + _ConsultationHistoryScreenState(); +} + +class _ConsultationHistoryScreenState + extends ConsumerState<ConsultationHistoryScreen> { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + String _formatDate(DateTime dt) { + final dayNames = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Minggu']; + final monthNames = [ + 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', + 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember' + ]; + final dayName = dayNames[dt.weekday - 1]; + final monthName = monthNames[dt.month - 1]; + return '$dayName, ${dt.day} $monthName ${dt.year}'; + } + + String _formatTime(DateTime dt) { + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + /// Opens the dedicated session logs & advice screen for the selected psychologist + void _openPsychologistLogsPage( + BuildContext context, + PsychologistEntity psychologist, + List<ConsultationSessionEntity> sessions, + ) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ConsultationSessionLogsScreen( + psychologist: psychologist, + sessions: sessions, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final sessionsAsync = ref.watch(consultationSessionsProvider); + final allSessions = sessionsAsync.value ?? []; + + // Group sessions by psychologist + final Map<String, List<ConsultationSessionEntity>> psychologistSessionsMap = {}; + for (final s in allSessions) { + final psyId = s.psychologist.id; + if (!psychologistSessionsMap.containsKey(psyId)) { + psychologistSessionsMap[psyId] = []; + } + psychologistSessionsMap[psyId]!.add(s); + } + + final List<PsychologistEntity> hiredPsychologists = psychologistSessionsMap.keys + .map((psyId) => psychologistSessionsMap[psyId]!.first.psychologist) + .toList(); + + final filteredPsychologists = hiredPsychologists.where((p) { + if (_searchQuery.isEmpty) return true; + final nameMatches = p.name.toLowerCase().contains(_searchQuery); + final titleMatches = p.title.toLowerCase().contains(_searchQuery); + final specMatches = p.specialties.any((s) => s.toLowerCase().contains(_searchQuery)); + return nameMatches || titleMatches || specMatches; + }).toList(); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + // 1. Navigation Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Riwayat Bicara & Konseling', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Daftar psikolog yang pernah disewa & catatan saran per sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + + // 2. Search Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: _searchController, + onChanged: (val) { + setState(() { + _searchQuery = val.trim().toLowerCase(); + }); + }, + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, + ), + decoration: InputDecoration( + hintText: 'Cari nama psikolog atau relawan...', + hintStyle: GoogleFonts.plusJakartaSans( + fontSize: 13, + color: AppColors.textLight, + ), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.primary, + size: 20, + ), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded, size: 18, color: AppColors.textSecondary), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + ), + ), + const SizedBox(height: 6), + + // 3. Scrollable Body + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section Title & Counter + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'PSIKOLOG & RELAWAN', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${filteredPsychologists.length}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // List of Hired Psychologists / Volunteers or Empty States + if (hiredPsychologists.isEmpty) + _buildEmptyState() + else if (filteredPsychologists.isEmpty) + _buildSearchEmptyState() + else + ...filteredPsychologists.map((psychologist) { + final sessions = psychologistSessionsMap[psychologist.id] ?? []; + return _buildHiredPsychologistCard(psychologist, sessions); + }), + + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildSearchEmptyState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + const Icon( + Icons.search_off_rounded, + size: 42, + color: Color(0xFF94A3B8), + ), + const SizedBox(height: 12), + Text( + 'Tidak Ditemukan', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Tidak ada psikolog atau relawan dengan nama "$_searchQuery"', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + child: Text( + 'Reset Pencarian', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + ); + } + + Widget _buildHiredPsychologistCard( + PsychologistEntity psychologist, + List<ConsultationSessionEntity> sessions, + ) { + final latestSession = sessions.isNotEmpty ? sessions.first : null; + final latestAdvice = (latestSession != null && latestSession.adviceList.isNotEmpty) + ? latestSession.adviceList.first + : (latestSession?.diagnosisSummary ?? 'Konsultasi selesai dengan baik.'); + + return InkWell( + onTap: () => _openPsychologistLogsPage(context, psychologist, sessions), + borderRadius: BorderRadius.circular(18), + child: Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 24, + backgroundImage: NetworkImage(psychologist.avatarUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 14.5, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(5), + ), + child: Text( + '${sessions.length} Sesi Terlaksana', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 6), + if (latestSession != null) + Text( + 'Terakhir: ${_formatDate(latestSession.sessionDateTime).split(',')[0]}', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + color: AppColors.textLight, + ), + ), + ], + ), + ], + ), + ), + const Icon( + Icons.arrow_forward_ios_rounded, + size: 14, + color: Color(0xFF94A3B8), + ), + ], + ), + + const SizedBox(height: 12), + + // Highlight of latest advice + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF0FDF4), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFBBF7D0)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.lightbulb_outline_rounded, + size: 15, + color: Color(0xFF16A34A), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Saran Terakhir: "$latestAdvice"', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: const Color(0xFF15803D), + height: 1.35, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + const SizedBox(height: 12), + + // Action Buttons + Row( + children: [ + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textPrimary, + side: const BorderSide(color: Color(0xFFCBD5E1)), + padding: const EdgeInsets.symmetric(vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PsychologistDetailScreen( + psychologist: psychologist, + ), + ), + ); + }, + child: Text( + 'Profil Konselor', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 8), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => _openPsychologistLogsPage( + context, + psychologist, + sessions, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.assignment_rounded, size: 15), + const SizedBox(width: 6), + Text( + 'Lihat Log & Saran Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildEmptyState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Color(0xFFF1F5F9), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.forum_outlined, + size: 40, + color: Color(0xFF94A3B8), + ), + ), + const SizedBox(height: 14), + Text( + 'Belum Ada Riwayat Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + 'Setelah kamu menyelesaikan sesi konsultasi video call dengan psikolog atau relawan, catatan saran dan evaluasi klinis per sesi akan otomatis tercatat di sini.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 18), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => Navigator.pop(context), + child: Text( + 'Cari Psikolog Sekarang', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/consultation_schedule_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_schedule_screen.dart new file mode 100644 index 0000000..a96dce1 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_schedule_screen.dart @@ -0,0 +1,1009 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../providers/care_provider.dart'; +import '../widgets/clinic_location_map_card.dart'; +import '../widgets/consultation_result_bottom_sheet.dart'; +import 'psychologist_detail_screen.dart'; +import 'psychologist_video_call_screen.dart'; + +/// Full-page screen for managing consultation schedules and entering live video calls. +class ConsultationScheduleScreen extends ConsumerStatefulWidget { + const ConsultationScheduleScreen({super.key}); + + @override + ConsumerState<ConsultationScheduleScreen> createState() => + _ConsultationScheduleScreenState(); +} + +class _ConsultationScheduleScreenState + extends ConsumerState<ConsultationScheduleScreen> { + String _selectedTab = 'upcoming'; // 'upcoming', 'completed', 'all' + + String _formatTime(DateTime dt) { + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + String _formatDate(DateTime dt) { + final dayNames = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Minggu']; + final monthNames = [ + 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', + 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember' + ]; + final dayName = dayNames[dt.weekday - 1]; + final monthName = monthNames[dt.month - 1]; + return '$dayName, ${dt.day} $monthName ${dt.year}'; + } + + bool _isSessionTimeNow(ConsultationSessionEntity session) { + if (session.type != ConsultationType.onlineVideo) return false; + + final now = DateTime.now(); + // Buka akses masuk mulai 10 menit sebelum jadwal sesi + final startTime = session.sessionDateTime.subtract(const Duration(minutes: 10)); + // Sesi berakhir setelah durasi + 15 menit grace period + final endTime = session.sessionDateTime.add(Duration(minutes: session.durationMinutes + 15)); + + return now.isAfter(startTime) && now.isBefore(endTime); + } + + void _showNotYetSessionTimeDialog(ConsultationSessionEntity session) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.lock_clock_rounded, + color: Color(0xFFD97706), + size: 22, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Belum Waktunya Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ruang video call bersama ${session.psychologist.name} belum dapat dimasuki.', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + const Icon( + Icons.access_time_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Jadwal: ${_formatDate(session.sessionDateTime)} • ${_formatTime(session.sessionDateTime)} WIB', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Text( + 'Pintu sesi video call akan otomatis dibuka 10 menit sebelum jam sesi dimulai. Silakan kembali saat jam konsultasi telah tiba.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + ], + ), + actions: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => Navigator.pop(ctx), + child: Text( + 'Mengerti', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } + + void _showSessionResult(ConsultationSessionEntity session) { + ConsultationResultBottomSheet.show(context, session); + } + + void _launchVideoCall(ConsultationSessionEntity session) { + if (!_isSessionTimeNow(session)) { + _showNotYetSessionTimeDialog(session); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PsychologistVideoCallScreen(session: session), + ), + ); + } + + @override + Widget build(BuildContext context) { + final sessionsAsync = ref.watch(consultationSessionsProvider); + final allSessions = sessionsAsync.value ?? []; + + final upcomingSessions = allSessions + .where((s) => s.status == ConsultationStatus.upcoming) + .toList(); + final completedSessions = allSessions + .where((s) => s.status == ConsultationStatus.completed) + .toList(); + + List<ConsultationSessionEntity> displayedSessions; + if (_selectedTab == 'upcoming') { + displayedSessions = upcomingSessions; + } else if (_selectedTab == 'completed') { + displayedSessions = completedSessions; + } else { + displayedSessions = allSessions; + } + + // Identify ready or imminent session + final readySession = upcomingSessions.isNotEmpty ? upcomingSessions.first : null; + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + // 1. Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Jadwal Sesi Mendatang', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Kelola janji konsultasi aktif & riwayat jadwalmu', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + + // 2. Scrollable Body + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Highlight Banner: Ready to Join Video Call Banner + if (readySession != null) ...[ + _buildReadyVideoCallBanner(readySession), + const SizedBox(height: 20), + ], + + // Filter Tabs (Sesi Aktif, Selesai, Semua) + _buildFilterTabs( + upcomingCount: upcomingSessions.length, + completedCount: completedSessions.length, + totalCount: allSessions.length, + ), + + const SizedBox(height: 16), + + // Session List + if (displayedSessions.isEmpty) + _buildEmptyState() + else + ...displayedSessions.map((session) { + return _buildSessionCard(session); + }), + + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ), + ); + } + + /// Special Highlight Banner for Live / Imminent Video Call or Offline Session + Widget _buildReadyVideoCallBanner(ConsultationSessionEntity session) { + final isOnline = session.type == ConsultationType.onlineVideo; + final isTimeNow = isOnline && _isSessionTimeNow(session); + + Color bgGradientStart; + Color bgGradientEnd; + Color badgeColor; + String badgeText; + IconData badgeIcon; + + if (!isOnline) { + bgGradientStart = const Color(0xFF0F766E); + bgGradientEnd = const Color(0xFF14B8A6); + badgeColor = const Color(0xFF0D9488); + badgeText = 'Sesi Tatap Muka di Klinik (Offline)'; + badgeIcon = Icons.storefront_rounded; + } else if (isTimeNow) { + bgGradientStart = AppColors.primary; + bgGradientEnd = const Color(0xFF6B71E8); + badgeColor = AppColors.success; + badgeText = 'Waktunya Sesi Konsultasi!'; + badgeIcon = Icons.videocam_rounded; + } else { + bgGradientStart = const Color(0xFF334155); + bgGradientEnd = const Color(0xFF475569); + badgeColor = const Color(0xFFF59E0B); + badgeText = 'Jadwal Sesi Mendatang'; + badgeIcon = Icons.lock_clock_rounded; + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [bgGradientStart, bgGradientEnd], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(22), + boxShadow: [ + BoxShadow( + color: bgGradientStart.withValues(alpha: 0.3), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(badgeIcon, size: 14, color: Colors.white), + const SizedBox(width: 5), + Text( + badgeText, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ], + ), + ), + Text( + 'Sesi #${session.sessionNumber}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: Colors.white70, + ), + ), + ], + ), + const SizedBox(height: 14), + + // Psychologist Info + Row( + children: [ + Stack( + children: [ + CircleAvatar( + radius: 26, + backgroundImage: NetworkImage(session.psychologist.avatarUrl), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: isTimeNow || !isOnline + ? AppColors.success + : const Color(0xFFF59E0B), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + session.psychologist.typeLabel, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.secondary, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.access_time_filled_rounded, size: 15, color: Colors.white), + const SizedBox(width: 8), + Expanded( + child: Text( + !isOnline + ? '${_formatDate(session.sessionDateTime)} • ${_formatTime(session.sessionDateTime)} WIB (${session.locationName ?? 'Klinik Mitra'})' + : isTimeNow + ? '${_formatDate(session.sessionDateTime)} • ${_formatTime(session.sessionDateTime)} WIB (Sedang Berlangsung)' + : '${_formatDate(session.sessionDateTime)} • ${_formatTime(session.sessionDateTime)} WIB (Dibuka 10 mnt sebelum sesi)', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Action Button based on Type and Time validity + if (!isOnline) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF0F766E), + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: () { + ClinicLocationMapCard.launchRandomGoogleMaps( + context: context, + query: session.locationName ?? + session.psychologist.clinicAffiliation, + ); + }, + icon: const Icon(Icons.map_rounded, size: 18), + label: Text( + 'Buka di Google Maps', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + ) + else if (isTimeNow) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: () => _launchVideoCall(session), + icon: const Icon(Icons.video_call_rounded, size: 20), + label: Text( + 'Masuk Video Call Sekarang', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + ) + else + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white.withValues(alpha: 0.16), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: Colors.white.withValues(alpha: 0.25)), + ), + ), + onPressed: () => _showNotYetSessionTimeDialog(session), + icon: const Icon(Icons.lock_clock_rounded, size: 19), + label: Text( + 'Belum Waktunya Masuk Video Call', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildFilterTabs({ + required int upcomingCount, + required int completedCount, + required int totalCount, + }) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Expanded( + child: _buildTabButton( + title: 'Sesi Aktif ($upcomingCount)', + keyTab: 'upcoming', + ), + ), + Expanded( + child: _buildTabButton( + title: 'Selesai ($completedCount)', + keyTab: 'completed', + ), + ), + Expanded( + child: _buildTabButton( + title: 'Semua ($totalCount)', + keyTab: 'all', + ), + ), + ], + ), + ); + } + + Widget _buildTabButton({ + required String title, + required String keyTab, + }) { + final isActive = _selectedTab == keyTab; + return GestureDetector( + onTap: () => setState(() => _selectedTab = keyTab), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isActive ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: isActive + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Center( + child: Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: isActive ? FontWeight.w800 : FontWeight.w600, + color: isActive ? AppColors.textPrimary : AppColors.textSecondary, + ), + ), + ), + ), + ); + } + + Widget _buildSessionCard(ConsultationSessionEntity session) { + final isUpcoming = session.status == ConsultationStatus.upcoming; + final isCompleted = session.status == ConsultationStatus.completed; + + return Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: isUpcoming ? AppColors.primaryContainer : const Color(0xFFE2E8F0), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row + Row( + children: [ + CircleAvatar( + radius: 22, + backgroundImage: NetworkImage(session.psychologist.avatarUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + session.psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3.5), + decoration: BoxDecoration( + color: isUpcoming + ? (session.type == ConsultationType.offlineClinic + ? const Color(0xFFCCFBF1) + : AppColors.successContainer) + : isCompleted + ? const Color(0xFFEEF2FF) + : const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + isUpcoming + ? (session.type == ConsultationType.offlineClinic + ? 'Tatap Muka' + : 'Online Video') + : isCompleted + ? 'Selesai' + : 'Dibatalkan', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: isUpcoming + ? (session.type == ConsultationType.offlineClinic + ? const Color(0xFF0F766E) + : AppColors.successDark) + : isCompleted + ? AppColors.primary + : AppColors.textSecondary, + ), + ), + ), + ], + ), + + const SizedBox(height: 12), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 10), + + // Date & Time + Row( + children: [ + const Icon( + Icons.calendar_today_rounded, + size: 14, + color: AppColors.primary, + ), + const SizedBox(width: 6), + Text( + _formatDate(session.sessionDateTime), + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(width: 8), + Text( + '• ${_formatTime(session.sessionDateTime)} WIB', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + + // Complaints / Notes + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.format_quote_rounded, + size: 16, + color: Color(0xFF94A3B8), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Keluhan: "${session.notes}"', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontStyle: FontStyle.italic, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ), + ], + ), + ), + ], + + // Offline Clinic Map Preview Card + if (session.type == ConsultationType.offlineClinic) ...[ + const SizedBox(height: 10), + ClinicLocationMapCard( + clinicName: session.locationName ?? + session.psychologist.clinicAffiliation ?? + 'Klinik Psikologi Sahabat Jiwa', + address: 'Jl. Kemanggisan Raya No. 45, Palmerah, Jakarta Barat', + ), + ], + + const SizedBox(height: 12), + + // Actions Row + Row( + children: [ + // Detail button + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textPrimary, + side: const BorderSide(color: Color(0xFFCBD5E1)), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PsychologistDetailScreen( + psychologist: session.psychologist, + ), + ), + ); + }, + child: Text( + 'Detail Konselor', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + + const SizedBox(width: 8), + + // Action Button based on Type, Time & Status + if (isUpcoming) ...[ + if (session.type == ConsultationType.offlineClinic) + Expanded( + flex: 2, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0D9488), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () { + ClinicLocationMapCard.launchRandomGoogleMaps( + context: context, + query: session.locationName ?? + session.psychologist.clinicAffiliation, + ); + }, + icon: const Icon(Icons.map_rounded, size: 16), + label: Text( + 'Buka di Maps', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ) + else if (_isSessionTimeNow(session)) + Expanded( + flex: 2, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => _launchVideoCall(session), + icon: const Icon(Icons.videocam_rounded, size: 16), + label: Text( + 'Masuk Video Call', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ) + else + Expanded( + flex: 2, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF1F5F9), + foregroundColor: AppColors.textSecondary, + padding: const EdgeInsets.symmetric(vertical: 10), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: const BorderSide(color: Color(0xFFCBD5E1)), + ), + ), + onPressed: () => _showNotYetSessionTimeDialog(session), + icon: const Icon(Icons.lock_outline_rounded, size: 15), + label: Text( + 'Belum Waktunya', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + ), + ), + ] else if (isCompleted) ...[ + Expanded( + flex: 2, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => _showSessionResult(session), + icon: const Icon(Icons.assignment_turned_in_rounded, size: 16), + label: Text( + 'Lihat Saran Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ], + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Color(0xFFF1F5F9), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.calendar_month_outlined, + size: 40, + color: Color(0xFF94A3B8), + ), + ), + const SizedBox(height: 14), + Text( + 'Tidak Ada Jadwal Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + 'Kamu belum memiliki jadwal sesi pada kategori ini. Silakan cari psikolog untuk menjadwalkan konsultasi baru.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 18), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => Navigator.pop(context), + child: Text( + 'Cari Psikolog Sekarang', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/consultation_session_logs_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_session_logs_screen.dart new file mode 100644 index 0000000..0b1b96c --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_session_logs_screen.dart @@ -0,0 +1,917 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../providers/care_provider.dart'; +import '../widgets/booking_consultation_sheet.dart'; +import 'psychologist_detail_screen.dart'; + +/// Dedicated screen for displaying detailed clinical session logs, evaluations, +/// and actionable recommendations provided by a psychologist. +class ConsultationSessionLogsScreen extends ConsumerStatefulWidget { + final PsychologistEntity psychologist; + final List<ConsultationSessionEntity> sessions; + + const ConsultationSessionLogsScreen({ + super.key, + required this.psychologist, + required this.sessions, + }); + + @override + ConsumerState<ConsultationSessionLogsScreen> createState() => + _ConsultationSessionLogsScreenState(); +} + +class _ConsultationSessionLogsScreenState + extends ConsumerState<ConsultationSessionLogsScreen> { + DateTime? _selectedDate; // null means 'Semua Tanggal' + + String _formatDate(DateTime dt) { + final months = [ + 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', + 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember' + ]; + return '${dt.day} ${months[dt.month - 1]} ${dt.year}'; + } + + String _formatShortDate(DateTime dt) { + final months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', + 'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des' + ]; + return '${dt.day} ${months[dt.month - 1]} ${dt.year}'; + } + + String _formatTime(DateTime dt) { + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + @override + Widget build(BuildContext context) { + // Reactively watch sessions from provider if available, fallback to passed sessions + final sessionsAsync = ref.watch(consultationSessionsProvider); + final allSessions = sessionsAsync.value ?? []; + final psychologistSessions = allSessions + .where((s) => s.psychologist.id == widget.psychologist.id) + .toList(); + + final activeSessions = psychologistSessions.isNotEmpty + ? psychologistSessions + : widget.sessions; + + final displayedSessions = _selectedDate == null + ? activeSessions + : activeSessions + .where((s) => _isSameDay(s.sessionDateTime, _selectedDate!)) + .toList(); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + // 1. Navigation Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Log & Saran Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Catatan klinis & rekomendasi per sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + + // 2. Scrollable Body + Expanded( + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + children: [ + // Psychologist Overview Card + _buildPsychologistHeaderCard(activeSessions.length), + const SizedBox(height: 14), + + // Confidentiality Note Banner + _buildConfidentialityBanner(), + const SizedBox(height: 14), + + // Date Filter Bar + if (activeSessions.isNotEmpty) ...[ + _buildDateFilterBar(activeSessions), + const SizedBox(height: 14), + ], + + // Session Logs List or Empty State + if (displayedSessions.isEmpty) + _buildEmptyState() + else + ...displayedSessions.map((session) { + return _buildSessionLogCard(session); + }), + + const SizedBox(height: 24), + ], + ), + ), + + // 3. Persistent Bottom Action Dock + _buildBottomActionDock(), + ], + ), + ), + ); + } + + /// Psychologist Summary Header Card with Profile Navigation + Widget _buildPsychologistHeaderCard(int totalSessionsCount) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PsychologistDetailScreen( + psychologist: widget.psychologist, + ), + ), + ); + }, + borderRadius: BorderRadius.circular(18), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + Stack( + children: [ + CircleAvatar( + radius: 26, + backgroundImage: NetworkImage(widget.psychologist.avatarUrl), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + widget.psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.star_rounded, size: 14, color: Color(0xFFF59E0B)), + const SizedBox(width: 3), + Text( + '${widget.psychologist.rating}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(width: 6), + Text( + '• $totalSessionsCount Sesi Dijalani', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right_rounded, + size: 22, + color: AppColors.textLight, + ), + ], + ), + ), + ), + ); + } + + /// Informative banner emphasizing privacy and medical ethics + Widget _buildConfidentialityBanner() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.primaryContainer), + ), + child: Row( + children: [ + const Icon( + Icons.lock_outline_rounded, + size: 18, + color: AppColors.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Catatan & ringkasan evaluasi ini dilindungi kerahasiaan medis psikolog untuk memantau progres kesehatan mental Anda.', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.primary, + fontWeight: FontWeight.w600, + height: 1.35, + ), + ), + ), + ], + ), + ); + } + + /// Horizontal date filter chips for filtering session logs by date + Widget _buildDateFilterBar(List<ConsultationSessionEntity> sessions) { + final uniqueDates = <DateTime>{}; + for (final s in sessions) { + uniqueDates.add(DateTime( + s.sessionDateTime.year, + s.sessionDateTime.month, + s.sessionDateTime.day, + )); + } + final sortedDates = uniqueDates.toList()..sort((a, b) => b.compareTo(a)); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + // Calendar picker button + InkWell( + onTap: () => _pickDate(context, sessions), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7.5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: _selectedDate != null ? AppColors.primary : const Color(0xFFE2E8F0), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.calendar_month_rounded, + size: 16, + color: _selectedDate != null ? AppColors.primary : AppColors.textSecondary, + ), + const SizedBox(width: 4), + Icon( + Icons.arrow_drop_down_rounded, + size: 16, + color: _selectedDate != null ? AppColors.primary : AppColors.textSecondary, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + + // 'Semua Tanggal' Chip + ChoiceChip( + label: Text('Semua Tanggal (${sessions.length})'), + selected: _selectedDate == null, + onSelected: (_) => setState(() => _selectedDate = null), + selectedColor: AppColors.primary, + backgroundColor: Colors.white, + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: _selectedDate == null ? Colors.white : AppColors.textPrimary, + ), + side: BorderSide( + color: _selectedDate == null ? AppColors.primary : const Color(0xFFE2E8F0), + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + const SizedBox(width: 8), + + // Date Chips for unique session dates + ...sortedDates.map((date) { + final isSelected = _selectedDate != null && _isSameDay(_selectedDate!, date); + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(_formatShortDate(date)), + selected: isSelected, + onSelected: (_) => setState(() { + _selectedDate = isSelected ? null : date; + }), + selectedColor: AppColors.primary, + backgroundColor: Colors.white, + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + side: BorderSide( + color: isSelected ? AppColors.primary : const Color(0xFFE2E8F0), + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + }), + + // If a custom date was picked outside the existing list + if (_selectedDate != null && !sortedDates.any((d) => _isSameDay(d, _selectedDate!))) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + avatar: const Icon(Icons.check, size: 14, color: Colors.white), + label: Text(_formatShortDate(_selectedDate!)), + selected: true, + onSelected: (_) => setState(() => _selectedDate = null), + selectedColor: AppColors.primary, + backgroundColor: Colors.white, + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + side: const BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ), + ); + } + + /// Open Date Picker modal + Future<void> _pickDate( + BuildContext context, + List<ConsultationSessionEntity> sessions, + ) async { + final now = DateTime.now(); + final initial = _selectedDate ?? + (sessions.isNotEmpty ? sessions.first.sessionDateTime : now); + + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(2020), + lastDate: DateTime(2035), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: AppColors.textPrimary, + ), + ), + child: child ?? const SizedBox.shrink(), + ); + }, + ); + + if (picked != null) { + setState(() { + _selectedDate = picked; + }); + } + } + + /// Session Log Detail Card + Widget _buildSessionLogCard(ConsultationSessionEntity session) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row: Session # & Mode & Duration + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3.5), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Sesi #${session.sessionNumber}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + session.isOnlineVideo + ? Icons.videocam_rounded + : Icons.place_rounded, + size: 13, + color: AppColors.textSecondary, + ), + const SizedBox(width: 4), + Text( + session.isOnlineVideo ? 'Video Call' : 'Tatap Muka', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${session.durationMinutes} Menit', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + + const SizedBox(height: 10), + + // Date & Time + Row( + children: [ + const Icon( + Icons.calendar_today_rounded, + size: 14, + color: AppColors.primary, + ), + const SizedBox(width: 6), + Text( + '${_formatDate(session.sessionDateTime)} • ${_formatTime(session.sessionDateTime)} WIB', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + + const SizedBox(height: 12), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 12), + + // 1. Initial Complaint / Keluhan Pasien + if (session.notes != null && session.notes!.isNotEmpty) ...[ + Text( + 'TOPIK & KELUHAN AWAL', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFF1F5F9)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.format_quote_rounded, + size: 16, + color: Color(0xFF94A3B8), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + '"${session.notes}"', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontStyle: FontStyle.italic, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + ], + + // 2. Clinical Evaluation & Diagnosis Summary + if (session.diagnosisSummary != null && session.diagnosisSummary!.isNotEmpty) ...[ + Text( + 'RINGKASAN EVALUASI PSIKOLOG', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.primaryContainer), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.psychology_outlined, + size: 18, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + session.diagnosisSummary!, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + ], + + // 3. Saran & Rekomendasi Klinis (Aligned with Eva's Success Palette) + if (session.adviceList.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.successContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.success.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.lightbulb_rounded, + size: 17, + color: AppColors.successDark, + ), + const SizedBox(width: 6), + Text( + 'Saran & Rekomendasi Psikolog:', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w800, + color: AppColors.successDark, + ), + ), + ], + ), + const SizedBox(height: 10), + ...session.adviceList.asMap().entries.map((entry) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2), + width: 18, + height: 18, + decoration: BoxDecoration( + color: AppColors.successContainer, + shape: BoxShape.circle, + border: Border.all(color: AppColors.success), + ), + child: Center( + child: Text( + '${entry.key + 1}', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w800, + color: AppColors.successDark, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + entry.value, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + ), + ], + ), + ); + }), + ], + ), + ), + const SizedBox(height: 14), + ], + + // 4. Action Items / Latihan Mandiri (Homework) + if (session.actionItems.isNotEmpty) ...[ + Text( + 'LATIHAN MANDIRI (ACTION ITEMS)', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 6), + ...session.actionItems.map((item) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.check_circle_outline_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + height: 1.35, + ), + ), + ), + ], + ), + ); + }), + const SizedBox(height: 12), + ], + + // 5. Mood Progress Transition + if (session.moodBefore != null || session.moodAfter != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + const Icon( + Icons.trending_up_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Perubahan Kondisi Emosional: ${session.moodBefore ?? '-'} ➔ ${session.moodAfter ?? 'Membaik'}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + /// Empty state when no sessions match filter + Widget _buildEmptyState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + const Icon( + Icons.event_busy_rounded, + size: 40, + color: Color(0xFF94A3B8), + ), + const SizedBox(height: 10), + Text( + _selectedDate != null + ? 'Tidak ada catatan sesi pada ${_formatShortDate(_selectedDate!)}' + : 'Tidak ada catatan sesi yang ditemukan', + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + if (_selectedDate != null) ...[ + const SizedBox(height: 10), + TextButton( + onPressed: () => setState(() => _selectedDate = null), + child: Text( + 'Tampilkan Semua Tanggal', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ], + ), + ); + } + + /// Persistent bottom dock for rebooking / scheduling new sessions + Widget _buildBottomActionDock() { + final counselorFirstName = widget.psychologist.name.split(',')[0]; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + border: const Border(top: BorderSide(color: Color(0xFFE2E8F0))), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, -3), + ), + ], + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 13), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: () { + BookingConsultationSheet.show(context, widget.psychologist); + }, + icon: const Icon(Icons.event_available_rounded, size: 18), + label: Text( + 'Jadwalkan Sesi Baru Bersama $counselorFirstName', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_detail_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_detail_screen.dart new file mode 100644 index 0000000..39db460 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_detail_screen.dart @@ -0,0 +1,1175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../../../widgets/glass_card.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../widgets/booking_consultation_sheet.dart'; +import '../widgets/psychologist_certificate_carousel.dart'; + +/// Comprehensive profile detail screen for Psychologists and Volunteers. +class PsychologistDetailScreen extends ConsumerWidget { + final PsychologistEntity psychologist; + + const PsychologistDetailScreen({ + super.key, + required this.psychologist, + }); + + String _formatCoinFee(int coins) { + if (coins <= 0) return 'Gratis'; + return '$coins Koin'; + } + + Color _getTypeColor() { + switch (psychologist.practitionerType) { + case PractitionerType.professional: + return AppColors.primary; + case PractitionerType.freelance: + return const Color(0xFF4F46E5); + case PractitionerType.volunteer: + return const Color(0xFF0D9488); + } + } + + Color _getTypeBgColor() { + switch (psychologist.practitionerType) { + case PractitionerType.professional: + return AppColors.primaryContainer; + case PractitionerType.freelance: + return const Color(0xFFEEF2FF); + case PractitionerType.volunteer: + return const Color(0xFFCCFBF1); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final typeColor = _getTypeColor(); + final typeBgColor = _getTypeBgColor(); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + // 1. Navigation Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Profil Konselor', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + psychologist.typeLabel, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: typeColor, + ), + ), + ], + ), + ), + ], + ), + ), + + // 2. Scrollable Body + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Hero Profile Card + _buildHeroProfileCard(typeColor, typeBgColor), + + const SizedBox(height: 20), + + // Statistik Pasien yang Pernah Dirawat + _buildPatientStatsSection(), + + const SizedBox(height: 20), + + // About & Therapy Approach + _buildAboutSection(), + + const SizedBox(height: 20), + + // 5 Testimoni Pasien (Menggantikan Fokus Isu hingga Biaya Sesi) + _buildTestimonialsSection(context), + + const SizedBox(height: 32), + ], + ), + ), + ), + + // 3. Sticky Bottom Action Bar + _buildBottomBar(context), + ], + ), + ), + ); + } + + Widget _buildHeroProfileCard(Color typeColor, Color typeBgColor) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + CircleAvatar( + radius: 36, + backgroundImage: NetworkImage(psychologist.avatarUrl), + ), + Positioned( + bottom: 2, + right: 2, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (psychologist.isVolunteer) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFF0F9FF), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFBAE6FD)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.volunteer_activism_rounded, + size: 11, + color: Color(0xFF0EA5E9), + ), + const SizedBox(width: 4), + Text( + 'Relawan', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF0369A1), + ), + ), + ], + ), + ), + if (psychologist.hasPsychologistCertificate) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.school_rounded, + size: 11, + color: Color(0xFF6366F1), + ), + const SizedBox(width: 4), + Text( + 'Profesional', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF3730A3), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.verified_rounded, size: 14, color: AppColors.primary), + const SizedBox(width: 4), + Expanded( + child: Text( + psychologist.registrationNumber, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.primary, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 14), + + // Stats Overview + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem( + icon: Icons.star_rounded, + iconColor: const Color(0xFFF59E0B), + title: psychologist.rating.toString(), + subtitle: '${psychologist.reviewCount} Ulasan', + ), + Container(width: 1, height: 28, color: const Color(0xFFE2E8F0)), + _buildStatItem( + icon: Icons.work_outline_rounded, + iconColor: AppColors.primary, + title: '${psychologist.experienceYears} Thn', + subtitle: 'Pengalaman', + ), + Container(width: 1, height: 28, color: const Color(0xFFE2E8F0)), + _buildStatItem( + icon: Icons.timer_outlined, + iconColor: const Color(0xFF0D9488), + title: '${psychologist.durationMinutes} Mnt', + subtitle: 'Per Sesi', + ), + ], + ), + ], + ), + ); + } + + Widget _buildStatItem({ + required IconData icon, + required Color iconColor, + required String title, + required String subtitle, + }) { + return Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: iconColor), + const SizedBox(width: 4), + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ); + } + + Widget _buildPatientStatsSection() { + final stats = psychologist.patientStats; + + return GlassCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.insights_rounded, + size: 18, + color: AppColors.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Statistik Pasien Dirawat', + style: GoogleFonts.plusJakartaSans( + fontSize: 14.5, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Berdasarkan riwayat konsultasi & pemulihan klinis', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // 3 Metric Counters + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatMetricItem( + value: '${stats.totalPatientsTreated}+', + label: 'Pasien Dirawat', + icon: Icons.people_outline_rounded, + accentColor: AppColors.primary, + ), + Container(width: 1, height: 32, color: const Color(0xFFE2E8F0)), + _buildStatMetricItem( + value: '${stats.satisfactionRate.toStringAsFixed(1)}%', + label: 'Tingkat Puas', + icon: Icons.sentiment_very_satisfied_rounded, + accentColor: const Color(0xFF16A34A), + ), + Container(width: 1, height: 32, color: const Color(0xFFE2E8F0)), + _buildStatMetricItem( + value: '${stats.avgRecoverySessions.toStringAsFixed(1)} Sesi', + label: 'Rata-rata Selesai', + icon: Icons.check_circle_outline_rounded, + accentColor: const Color(0xFF0D9488), + ), + ], + ), + ), + + const SizedBox(height: 14), + + // Review Sentiment Ratio Bar (Positif vs Negatif) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFF16A34A), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '${stats.positiveReviewRate.toStringAsFixed(0)}% Ulasan Positif', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: const Color(0xFF15803D), + ), + ), + ], + ), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFDC2626), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '${stats.negativeReviewRate.toStringAsFixed(0)}% Catatan / Masukan', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: const Color(0xFFB91C1C), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + height: 8, + child: Row( + children: [ + Expanded( + flex: (stats.positiveReviewRate * 10).toInt(), + child: Container(color: const Color(0xFF22C55E)), + ), + Expanded( + flex: (stats.negativeReviewRate * 10).toInt(), + child: Container(color: const Color(0xFFEF4444)), + ), + ], + ), + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Clinical Recovery Description Banner + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF0FDF4), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFBBF7D0)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.verified_rounded, + size: 15, + color: Color(0xFF16A34A), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + stats.recoveryDescription, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: const Color(0xFF15803D), + height: 1.35, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatMetricItem({ + required String value, + required String label, + required IconData icon, + required Color accentColor, + }) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: accentColor), + const SizedBox(width: 4), + Text( + value, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + label, + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ); + } + + Widget _buildAboutSection() { + final title = psychologist.hasPsychologistCertificate + ? 'Tentang Psikolog & Kredensial Keahlian' + : 'Tentang Relawan & Kredensial Keahlian'; + + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + psychologist.hasPsychologistCertificate + ? Icons.psychology_outlined + : Icons.volunteer_activism_outlined, + size: 18, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 14.5, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + psychologist.bio, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 12), + + // Mitra Praktik / Afiliasi + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + Icon( + psychologist.clinicAffiliation != null + ? Icons.business_rounded + : Icons.group_work_outlined, + size: 15, + color: AppColors.primary, + ), + const SizedBox(width: 7), + Expanded( + child: Text( + psychologist.clinicAffiliation != null + ? 'Mitra Praktik: ${psychologist.clinicAffiliation}' + : 'Mitra Praktik: Relawan Komunitas Mandiri (Bebas Biaya)', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 14), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 12), + + // Sertifikat & Lisensi (Foto & Label Swipeable) + PsychologistCertificateCarousel( + certificateItems: psychologist.effectiveCertificateItems, + ), + ], + ), + ); + } + + /// 5 Patient Testimonials replacing the old Focus Issues & Session Cost sections + Widget _buildTestimonialsSection(BuildContext context) { + final allTestimonials = psychologist.effectiveTestimonials; + final displayedFive = allTestimonials.take(5).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + 'TESTIMONI PASIEN', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${allTestimonials.length}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + ], + ), + InkWell( + onTap: () => _showAllTestimonialsSheet(context), + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Lihat Semua', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.arrow_forward_ios_rounded, + size: 11, + color: AppColors.primary, + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + '5 ulasan pengalaman konseling pasien terverifikasi:', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + ...displayedFive.map((item) => _buildTestimonialCard(item)), + ], + ); + } + + Widget _buildTestimonialCard(PatientReviewItem item) { + final isPos = item.isPositive; + final sentimentBg = isPos ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2); + final sentimentBorder = isPos ? const Color(0xFFBBF7D0) : const Color(0xFFFECACA); + final sentimentText = isPos ? const Color(0xFF15803D) : const Color(0xFFB91C1C); + final sentimentIcon = isPos ? Icons.thumb_up_alt_rounded : Icons.thumb_down_alt_rounded; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row: Patient Profile, Rating Stars, Time, and Sentiment Badge + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: isPos ? const Color(0xFFDCFCE7) : const Color(0xFFFEE2E2), + child: Icon( + Icons.person_rounded, + size: 16, + color: sentimentText, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.patientName, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Row( + children: [ + Row( + children: List.generate(5, (starIndex) { + final fill = (starIndex + 1) <= item.rating.floor(); + return Icon( + fill ? Icons.star_rounded : Icons.star_half_rounded, + size: 12, + color: const Color(0xFFF59E0B), + ); + }), + ), + const SizedBox(width: 4), + Text( + item.rating.toStringAsFixed(1), + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: const Color(0xFFD97706), + ), + ), + const SizedBox(width: 6), + Text( + '• ${item.timeAgo}', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + ), + // Sentiment Icon (Thumbs up/down only, without Positif/Catatan text) + Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: sentimentBg, + shape: BoxShape.circle, + border: Border.all(color: sentimentBorder), + ), + child: Icon(sentimentIcon, size: 12, color: sentimentText), + ), + ], + ), + + const SizedBox(height: 10), + + // Ulasan Singkat (Tag Label) + if (item.label.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: sentimentBg, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.label_outline_rounded, + size: 12, + color: sentimentText, + ), + const SizedBox(width: 5), + Flexible( + child: Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: sentimentText, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 8), + + // Deskripsi Testimoni + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.format_quote_rounded, + size: 18, + color: Color(0xFF94A3B8), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + item.description, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontStyle: FontStyle.italic, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + ), + ], + ), + ], + ), + ); + } + + /// Interactive Modal Bottom Sheet allowing patients to see all testimonials with filters + void _showAllTestimonialsSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (modalContext) { + String activeFilter = 'semua'; + + return StatefulBuilder( + builder: (context, setModalState) { + final allTestimonials = psychologist.effectiveTestimonials; + final positiveList = allTestimonials.where((t) => t.isPositive).toList(); + final negativeList = allTestimonials.where((t) => t.isNegative).toList(); + + List<PatientReviewItem> displayedList; + if (activeFilter == 'positif') { + displayedList = positiveList; + } else if (activeFilter == 'negatif') { + displayedList = negativeList; + } else { + displayedList = allTestimonials; + } + + return Container( + height: MediaQuery.of(context).size.height * 0.85, + decoration: const BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // Drag Handle + Container( + margin: const EdgeInsets.only(top: 10, bottom: 8), + width: 36, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFCBD5E1), + borderRadius: BorderRadius.circular(2), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Semua Testimoni Pasien', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + '${allTestimonials.length} Ulasan Terverifikasi • ${psychologist.name}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.pop(modalContext), + icon: const Icon(Icons.close_rounded, size: 20), + color: AppColors.textSecondary, + ), + ], + ), + ), + + // Filter Chips (Semua, Positif, Catatan) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + _buildFilterChip( + label: 'Semua (${allTestimonials.length})', + isActive: activeFilter == 'semua', + activeColor: AppColors.primary, + onTap: () => setModalState(() => activeFilter = 'semua'), + ), + const SizedBox(width: 8), + _buildFilterChip( + label: 'Positif (${positiveList.length})', + isActive: activeFilter == 'positif', + activeColor: const Color(0xFF16A34A), + onTap: () => setModalState(() => activeFilter = 'positif'), + ), + const SizedBox(width: 8), + _buildFilterChip( + label: 'Catatan (${negativeList.length})', + isActive: activeFilter == 'negatif', + activeColor: const Color(0xFFDC2626), + onTap: () => setModalState(() => activeFilter = 'negatif'), + ), + ], + ), + ), + + const Divider(height: 1, color: Color(0xFFE2E8F0)), + + // Testimonial List + Expanded( + child: displayedList.isEmpty + ? Center( + child: Text( + 'Tidak ada ulasan pada kategori ini.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + itemCount: displayedList.length, + itemBuilder: (context, index) { + return _buildTestimonialCard(displayedList[index]); + }, + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _buildFilterChip({ + required String label, + required bool isActive, + required Color activeColor, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isActive ? activeColor : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isActive ? activeColor : const Color(0xFFE2E8F0), + ), + ), + child: Text( + label, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: isActive ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ); + } + + Widget _buildBottomBar(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + border: const Border(top: BorderSide(color: Color(0xFFE2E8F0), width: 1)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, -3), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Biaya Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + if (psychologist.coinsPerSession > 0) ...[ + const Icon( + Icons.monetization_on_rounded, + size: 16, + color: Color(0xFFFFB300), + ), + const SizedBox(width: 4), + ], + Text( + _formatCoinFee(psychologist.coinsPerSession), + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w800, + color: psychologist.coinsPerSession > 0 + ? AppColors.textPrimary + : const Color(0xFF0D9488), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: () { + BookingConsultationSheet.show(context, psychologist); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), + child: Text( + 'Jadwalkan Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_video_call_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_video_call_screen.dart new file mode 100644 index 0000000..906f702 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_video_call_screen.dart @@ -0,0 +1,841 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../providers/care_provider.dart'; +import '../widgets/consultation_result_bottom_sheet.dart'; + +/// Immersive Mock Video Call screen with licensed psychologists and peer counselors. +class PsychologistVideoCallScreen extends ConsumerStatefulWidget { + final ConsultationSessionEntity session; + + const PsychologistVideoCallScreen({ + super.key, + required this.session, + }); + + @override + ConsumerState<PsychologistVideoCallScreen> createState() => + _PsychologistVideoCallScreenState(); +} + +class _PsychologistVideoCallScreenState + extends ConsumerState<PsychologistVideoCallScreen> + with SingleTickerProviderStateMixin { + late Timer _timer; + int _secondsElapsed = 0; + bool _isMicMuted = false; + bool _isCameraOff = false; + bool _isFrontCamera = true; + bool _isSpeakerOn = true; + bool _isPsychologistSpeaking = true; + late AnimationController _pulseController; + + final List<Map<String, String>> _liveMessages = [ + { + 'sender': 'psychologist', + 'text': 'Halo, selamat datang di sesi konsultasi online kita hari ini.', + 'time': '00:05', + }, + { + 'sender': 'psychologist', + 'text': + 'Ambil posisi yang paling nyaman ya. Bagaimana perasaanmu hari ini?', + 'time': '00:15', + }, + { + 'sender': 'psychologist', + 'text': + 'Mari kita mulai dengan latihan pernapasan diafragma 4-7-8 sejenak untuk menenangkan detak jantung.', + 'time': '00:30', + }, + ]; + + final TextEditingController _chatController = TextEditingController(); + + @override + void initState() { + super.initState(); + _startTimer(); + + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + + // Alternate speaking indicator every few seconds + Timer.periodic(const Duration(seconds: 4), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + setState(() { + _isPsychologistSpeaking = !_isPsychologistSpeaking; + }); + }); + } + + void _startTimer() { + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + _secondsElapsed++; + }); + } + }); + } + + @override + void dispose() { + _timer.cancel(); + _pulseController.dispose(); + _chatController.dispose(); + super.dispose(); + } + + String _formatTimer(int seconds) { + final mins = (seconds ~/ 60).toString().padLeft(2, '0'); + final secs = (seconds % 60).toString().padLeft(2, '0'); + return '$mins:$secs'; + } + + void _handleEndCall() { + showDialog( + context: context, + builder: (dialogCtx) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.call_end_rounded, + color: Color(0xFFDC2626), + size: 20, + ), + ), + const SizedBox(width: 10), + Text( + 'Akhiri Sesi Konsultasi?', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + content: Text( + 'Sesi akan diselesaikan dan catatan rangkuman beserta saran psikolog akan otomatis tersimpan di Riwayat Bicara.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textSecondary, + height: 1.45, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogCtx), + child: Text( + 'Lanjutkan Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFDC2626), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () async { + Navigator.pop(dialogCtx); + await _completeAndExitCall(); + }, + child: Text( + 'Ya, Selesaikan', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + }, + ); + } + + Future<void> _completeAndExitCall() async { + final advice = widget.session.adviceList.isNotEmpty + ? widget.session.adviceList + : const [ + 'Lakukan relaksasi napas 4-7-8 setiap pagi dan malam.', + 'Gunakan format restrukturisasi kognitif di Eva Diary saat overthinking.', + 'Jadwalkan sesi evaluasi lanjutan dalam 2 minggu ke depan.', + ]; + + final actionItems = widget.session.actionItems.isNotEmpty + ? widget.session.actionItems + : const [ + 'Tulis 3 hal yang disyukuri setiap malam di Jurnal Eva.', + 'Latih mindful pause 3 detik sebelum merespon saat merasa tegang.', + ]; + + const sessionSummary = + 'Sesi video call tatap muka online selesai dilaksanakan. Pasien menunjukkan perbaikan pemahaman emosi dan memiliki rencana aksi mandiri yang jelas.'; + const moodAfter = 'Lebih Tenang & Terarah (Skala 4/10)'; + + // 1. Mark session completed in Riverpod provider + await ref.read(consultationSessionsProvider.notifier).completeSession( + widget.session.id, + adviceList: advice, + actionItems: actionItems, + sessionSummary: sessionSummary, + moodAfter: moodAfter, + ); + + if (!mounted) return; + + final updatedSession = widget.session.copyWith( + status: ConsultationStatus.completed, + adviceList: advice, + actionItems: actionItems, + sessionSummary: sessionSummary, + moodAfter: moodAfter, + ); + + // 2. Langsung tampilkan bottom sheet saran sesi hasil konsultasi (bukan dilarikan ke log history) + await ConsultationResultBottomSheet.show( + context, + updatedSession, + onClose: () { + if (mounted) { + Navigator.pop(context); + } + }, + ); + + if (mounted) { + Navigator.pop(context); + } + } + + void _openLiveChatNotesSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + height: MediaQuery.of(context).size.height * 0.7, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // Drag Handle + Container( + margin: const EdgeInsets.only(top: 10, bottom: 8), + width: 36, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFCBD5E1), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon( + Icons.chat_bubble_outline_rounded, + size: 18, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Text( + 'Catatan & Chat Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + IconButton( + icon: const Icon(Icons.close_rounded, size: 20), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ), + const Divider(height: 1, color: Color(0xFFE2E8F0)), + + // Messages List + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _liveMessages.length, + itemBuilder: (context, index) { + final msg = _liveMessages[index]; + final isPsy = msg['sender'] == 'psychologist'; + + return Align( + alignment: isPsy ? Alignment.centerLeft : Alignment.centerRight, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.78, + ), + decoration: BoxDecoration( + color: isPsy + ? const Color(0xFFF1F5F9) + : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: isPsy + ? CrossAxisAlignment.start + : CrossAxisAlignment.end, + children: [ + Text( + isPsy + ? widget.session.psychologist.name + : 'Kamu (Pasien)', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: isPsy + ? AppColors.textSecondary + : AppColors.primary, + ), + ), + const SizedBox(height: 4), + Text( + msg['text'] ?? '', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textPrimary, + height: 1.35, + ), + ), + const SizedBox(height: 2), + Text( + msg['time'] ?? '', + style: GoogleFonts.plusJakartaSans( + fontSize: 9.5, + color: AppColors.textLight, + ), + ), + ], + ), + ), + ); + }, + ), + ), + + // Chat Input Box + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _chatController, + style: GoogleFonts.plusJakartaSans(fontSize: 13), + decoration: InputDecoration( + hintText: 'Kirim pesan atau catatan...', + hintStyle: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textLight, + ), + filled: true, + fillColor: const Color(0xFFF8FAFC), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send_rounded, color: AppColors.primary), + onPressed: () { + final text = _chatController.text.trim(); + if (text.isNotEmpty) { + setState(() { + _liveMessages.add({ + 'sender': 'user', + 'text': text, + 'time': _formatTimer(_secondsElapsed), + }); + }); + _chatController.clear(); + Navigator.pop(ctx); + } + }, + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final psychologist = widget.session.psychologist; + + return Scaffold( + backgroundColor: const Color(0xFF0F172A), + body: SafeArea( + child: Stack( + children: [ + // 1. Fullscreen Main Video Feed (Psychologist) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFF0F172A), + const Color(0xFF1E293B), + const Color(0xFF0F172A), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + // Psychologist Video Feed Mock Image + Opacity( + opacity: 0.88, + child: Image.network( + psychologist.avatarUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: const Color(0xFF1E293B), + child: const Icon( + Icons.person, + size: 100, + color: Colors.white30, + ), + ), + ), + ), + + // Cinematic Gradient Overlays + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black.withValues(alpha: 0.65), + Colors.transparent, + Colors.black.withValues(alpha: 0.85), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.45, 1.0], + ), + ), + ), + ), + + // Speaking Indicator Waves + if (_isPsychologistSpeaking) + Positioned( + bottom: 120, + left: 20, + right: 20, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: const Color(0xFF22C55E).withValues(alpha: 0.6), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Color(0xFF22C55E), + size: 16, + ), + const SizedBox(width: 8), + Text( + '${psychologist.name} sedang berbicara...', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + + // 2. Top Bar (Header & Timer) + Positioned( + top: 14, + left: 16, + right: 16, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Psychologist Header Info + Expanded( + child: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundImage: NetworkImage(psychologist.avatarUrl), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Row( + children: [ + Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + color: Color(0xFF22C55E), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + 'Online • Sesi #${widget.session.sessionNumber}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white70, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + + // Timer Badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.fiber_manual_record_rounded, + color: Color(0xFFEF4444), + size: 11, + ), + const SizedBox(width: 6), + Text( + _formatTimer(_secondsElapsed), + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ], + ), + ), + + // 3. User Floating PIP Camera Preview (Self-View) + Positioned( + top: 76, + right: 16, + child: GestureDetector( + onTap: () { + setState(() => _isFrontCamera = !_isFrontCamera); + }, + child: Container( + width: 104, + height: 148, + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withValues(alpha: 0.4), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + fit: StackFit.expand, + children: [ + if (_isCameraOff) + Container( + color: const Color(0xFF0F172A), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.videocam_off_rounded, + color: Colors.white54, + size: 26, + ), + const SizedBox(height: 4), + Text( + 'Kamera Mati', + style: GoogleFonts.plusJakartaSans( + fontSize: 9, + color: Colors.white54, + ), + ), + ], + ), + ), + ) + else + Container( + color: const Color(0xFF334155), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 24, + backgroundColor: AppColors.primary, + child: const Icon( + Icons.person, + size: 28, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + Text( + 'Kamu', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ), + // Camera Flip hint + Positioned( + bottom: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.flip_camera_ios_rounded, + size: 12, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + ), + + // 4. Bottom Video Call Action Control Bar + Positioned( + bottom: 24, + left: 20, + right: 20, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFF1E293B).withValues(alpha: 0.94), + borderRadius: BorderRadius.circular(28), + border: Border.all(color: Colors.white12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + // Mute / Unmute Mic + _buildControlButton( + icon: _isMicMuted + ? Icons.mic_off_rounded + : Icons.mic_rounded, + isActive: !_isMicMuted, + activeColor: Colors.white24, + iconColor: _isMicMuted + ? const Color(0xFFEF4444) + : Colors.white, + onTap: () => setState(() => _isMicMuted = !_isMicMuted), + ), + + // Camera On / Off + _buildControlButton( + icon: _isCameraOff + ? Icons.videocam_off_rounded + : Icons.videocam_rounded, + isActive: !_isCameraOff, + activeColor: Colors.white24, + iconColor: _isCameraOff + ? const Color(0xFFEF4444) + : Colors.white, + onTap: () => setState(() => _isCameraOff = !_isCameraOff), + ), + + // Flip Camera + _buildControlButton( + icon: Icons.flip_camera_ios_rounded, + isActive: true, + activeColor: Colors.white24, + iconColor: Colors.white, + onTap: () => setState(() => _isFrontCamera = !_isFrontCamera), + ), + + // Chat & Notes Drawer + _buildControlButton( + icon: Icons.chat_bubble_outline_rounded, + isActive: true, + activeColor: AppColors.primary, + iconColor: Colors.white, + onTap: _openLiveChatNotesSheet, + ), + + // End Call Button (Red) + GestureDetector( + onTap: _handleEndCall, + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Color(0xFFDC2626), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.call_end_rounded, + color: Colors.white, + size: 26, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildControlButton({ + required IconData icon, + required bool isActive, + required Color activeColor, + required Color iconColor, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: activeColor, + shape: BoxShape.circle, + ), + child: Icon(icon, color: iconColor, size: 22), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/booking_consultation_sheet.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/booking_consultation_sheet.dart new file mode 100644 index 0000000..c641d20 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/booking_consultation_sheet.dart @@ -0,0 +1,582 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../providers/care_provider.dart'; +import '../../../coin/presentation/providers/coin_provider.dart'; +import 'clinic_location_map_card.dart'; + +class BookingConsultationSheet extends ConsumerStatefulWidget { + final PsychologistEntity psychologist; + + const BookingConsultationSheet({ + super.key, + required this.psychologist, + }); + + static Future<void> show(BuildContext context, PsychologistEntity psychologist) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => BookingConsultationSheet(psychologist: psychologist), + ); + } + + @override + ConsumerState<BookingConsultationSheet> createState() => _BookingConsultationSheetState(); +} + +class _BookingConsultationSheetState extends ConsumerState<BookingConsultationSheet> { + ConsultationType _selectedType = ConsultationType.onlineVideo; + int _selectedDayOffset = 1; // 1 = Besok, 2 = Lusa + int _selectedTimeSlot = 0; // Index in slots + final TextEditingController _notesController = TextEditingController(); + bool _isLoading = false; + + final List<String> _timeSlots = [ + '09:00 WIB', + '13:30 WIB', + '16:00 WIB', + '19:30 WIB', + ]; + + @override + void dispose() { + _notesController.dispose(); + super.dispose(); + } + + Future<void> _handleConfirmBooking() async { + final requiredCoins = widget.psychologist.coinsPerSession; + final currentCoins = ref.read(coinBalanceProvider); + + if (requiredCoins > 0 && currentCoins < requiredCoins) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Saldo koin tidak mencukupi untuk konsultasi ini ($requiredCoins Koin).', + style: GoogleFonts.plusJakartaSans(fontSize: 12), + ), + backgroundColor: AppColors.error, + action: SnackBarAction( + label: 'Top Up', + textColor: Colors.white, + onPressed: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/coin_purchase'); + }, + ), + ), + ); + return; + } + + setState(() => _isLoading = true); + + final targetDate = DateTime.now().add(Duration(days: _selectedDayOffset)); + final slotParts = _timeSlots[_selectedTimeSlot].split(' ')[0].split(':'); + final hour = int.parse(slotParts[0]); + final minute = int.parse(slotParts[1]); + + final finalDateTime = DateTime( + targetDate.year, + targetDate.month, + targetDate.day, + hour, + minute, + ); + + final success = await ref.read(consultationSessionsProvider.notifier).bookNewSession( + psychologistId: widget.psychologist.id, + preferredDateTime: finalDateTime, + sessionType: _selectedType, + complaints: _notesController.text.trim(), + ); + + if (!mounted) return; + setState(() => _isLoading = false); + + if (success) { + // Deduct coins if required + if (requiredCoins > 0) { + await ref.read(coinBalanceProvider.notifier).spendCoins(requiredCoins); + } + if (!mounted) return; + Navigator.pop(context); + _showSuccessDialog(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Gagal menjadwalkan sesi. Silakan coba kembali.'), + backgroundColor: AppColors.error, + ), + ); + } + } + + void _showSuccessDialog() { + final coins = widget.psychologist.coinsPerSession; + final descriptionText = coins > 0 + ? 'Sesi konsultasi Anda bersama ${widget.psychologist.name} telah berhasil dijadwalkan dengan transaksi $coins Koin EVA.' + : 'Sesi konsultasi Anda bersama ${widget.psychologist.name} telah berhasil dijadwalkan secara gratis.'; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + contentPadding: const EdgeInsets.all(22), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 58, + height: 58, + decoration: const BoxDecoration( + color: AppColors.successContainer, + shape: BoxShape.circle, + ), + child: const Icon(Icons.event_available, color: AppColors.success, size: 34), + ), + const SizedBox(height: 14), + Text( + 'Jadwal Berhasil Dibuat!', + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + descriptionText, + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 18), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 11), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + 'Selesai', + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 12, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + + // Psychologist Summary Header + Row( + children: [ + CircleAvatar( + radius: 26, + backgroundColor: AppColors.primaryContainer, + backgroundImage: NetworkImage(widget.psychologist.avatarUrl), + onBackgroundImageError: (exception, stackTrace) {}, + child: Text( + widget.psychologist.name.substring(0, 2).toUpperCase(), + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + widget.psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + + // Mode Sesi Selector + Text( + 'Pilih Moda Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildTypeOption( + title: 'Online Video', + subtitle: 'Via Google Meet', + icon: Icons.videocam_outlined, + type: ConsultationType.onlineVideo, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildTypeOption( + title: 'Tatap Muka', + subtitle: widget.psychologist.clinicAffiliation ?? 'Klinik Mitra', + icon: Icons.location_city_outlined, + type: ConsultationType.offlineClinic, + ), + ), + ], + ), + + // Tunjukkan Maps Jika Moda Offline / Tatap Muka + if (_selectedType == ConsultationType.offlineClinic) ...[ + const SizedBox(height: 12), + ClinicLocationMapCard( + clinicName: widget.psychologist.clinicAffiliation ?? 'Klinik Mitra Eva Care', + address: 'Jl. Kemanggisan Raya No. 45, Palmerah, Jakarta Barat', + ), + ], + + const SizedBox(height: 18), + + // Tanggal Sesi + Text( + 'Pilih Hari', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + _buildDayChip('Besok', 1), + const SizedBox(width: 8), + _buildDayChip('Lusa', 2), + const SizedBox(width: 8), + _buildDayChip('3 Hari Lagi', 3), + ], + ), + const SizedBox(height: 16), + + // Pilihan Jam Sesi + Text( + 'Pilih Jam Sesi (Durasi ${widget.psychologist.durationMinutes} menit)', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(_timeSlots.length, (index) { + final isSelected = _selectedTimeSlot == index; + return ChoiceChip( + label: Text(_timeSlots[index]), + selected: isSelected, + onSelected: (_) => setState(() => _selectedTimeSlot = index), + selectedColor: AppColors.primary, + checkmarkColor: Colors.white, + backgroundColor: const Color(0xFFF1F5F9), + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isSelected ? AppColors.primary : Colors.transparent, + ), + ), + ); + }), + ), + const SizedBox(height: 16), + + // Catatan Singkat + Text( + 'Fokus atau Cerita Singkat (Opsional)', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + TextField( + controller: _notesController, + maxLines: 2, + decoration: InputDecoration( + hintText: 'Misal: Sedang merasa cemas menghadapi sidang/pekerjaan...', + hintStyle: GoogleFonts.plusJakartaSans(fontSize: 12, color: AppColors.textLight), + filled: true, + fillColor: const Color(0xFFF8FAFC), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + ), + ), + const SizedBox(height: 16), + + // Alat Transaksi: Koin EVA + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: const Color(0xFFF3F4FE), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.primaryContainer), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.monetization_on_rounded, + size: 16, + color: Color(0xFFFFB300), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Alat Transaksi: Koin EVA', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + Text( + widget.psychologist.coinsPerSession > 0 + ? 'Biaya: ${widget.psychologist.coinsPerSession} Koin • Saldo koinmu: ${ref.watch(coinBalanceProvider)} Koin' + : 'Bebas biaya koin (Gratis) • Saldo koinmu: ${ref.watch(coinBalanceProvider)} Koin', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + if (widget.psychologist.coinsPerSession > 0 && + ref.watch(coinBalanceProvider) < widget.psychologist.coinsPerSession) + InkWell( + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/coin_purchase'); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Top Up', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 18), + + // CTA Button + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleConfirmBooking, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: _isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : Text( + 'Konfirmasi Janji Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTypeOption({ + required String title, + required String subtitle, + required IconData icon, + required ConsultationType type, + }) { + final isSelected = _selectedType == type; + + return GestureDetector( + onTap: () => setState(() => _selectedType = type), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryContainer.withValues(alpha: 0.5) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFE2E8F0), + width: isSelected ? 1.5 : 1, + ), + ), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: isSelected ? AppColors.primary : AppColors.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: isSelected ? AppColors.primary : AppColors.textPrimary, + ), + ), + Text( + subtitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildDayChip(String label, int offset) { + final isSelected = _selectedDayOffset == offset; + + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (_) => setState(() => _selectedDayOffset = offset), + selectedColor: AppColors.primary, + checkmarkColor: Colors.white, + backgroundColor: const Color(0xFFF1F5F9), + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isSelected ? AppColors.primary : Colors.transparent, + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/care_quick_actions.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/care_quick_actions.dart new file mode 100644 index 0000000..8104735 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/care_quick_actions.dart @@ -0,0 +1,692 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../coin/presentation/providers/coin_provider.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../providers/care_provider.dart'; +import 'booking_consultation_sheet.dart'; + +/// 4 Quick Action Buttons for Care / Layanan Screen: +/// 1. Jadwal Sesi (Sesi yang telah dibooking) +/// 2. Riwayat Bicara (History pernah bicara dengan siapa saja) +/// 3. Koin Saya (Saldo koin & top up cepat) +/// 4. Panduan & FAQ (Panduan konseling, perbedaan psikolog & etika) +class CareQuickActions extends ConsumerWidget { + const CareQuickActions({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final coinBalance = ref.watch(coinBalanceProvider); + final sessionsAsync = ref.watch(consultationSessionsProvider); + + final upcomingCount = sessionsAsync.value + ?.where((s) => s.status == ConsultationStatus.upcoming) + .length ?? + 0; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _buildActionCard( + context: context, + title: 'Jadwal', + subtitle: upcomingCount > 0 ? '$upcomingCount Sesi Aktif' : 'Cek Jadwal', + badge: upcomingCount > 0 ? '$upcomingCount' : null, + icon: Icons.calendar_today_rounded, + iconColor: AppColors.primary, + iconBgColor: AppColors.primaryContainer, + onTap: () => Navigator.pushNamed(context, '/care/schedule'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildActionCard( + context: context, + title: 'Riwayat', + subtitle: 'History Sesi', + icon: Icons.history_rounded, + iconColor: const Color(0xFF6366F1), + iconBgColor: const Color(0xFFEEF2FF), + onTap: () => Navigator.pushNamed(context, '/care/history'), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _buildActionCard( + context: context, + title: 'Koin Saya', + subtitle: '$coinBalance Koin', + icon: Icons.monetization_on_rounded, + iconColor: const Color(0xFFD97706), + iconBgColor: const Color(0xFFFEF3C7), + onTap: () { + Navigator.pushNamed(context, '/coin_purchase'); + }, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildActionCard( + context: context, + title: 'Panduan', + subtitle: 'Etika & FAQ', + icon: Icons.help_outline_rounded, + iconColor: const Color(0xFF0D9488), + iconBgColor: const Color(0xFFCCFBF1), + onTap: () => _showCareGuideModal(context), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildActionCard({ + required BuildContext context, + required String title, + required String subtitle, + required IconData icon, + required Color iconColor, + required Color iconBgColor, + required VoidCallback onTap, + String? badge, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: iconBgColor, + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Icon(icon, color: iconColor, size: 20), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (badge != null) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + badge, + style: GoogleFonts.plusJakartaSans( + fontSize: 9, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + size: 16, + color: Color(0xFFCBD5E1), + ), + ], + ), + ), + ); + } + + void _showUpcomingSessionsModal(BuildContext context, WidgetRef ref) { + final sessions = ref.read(consultationSessionsProvider).value ?? []; + final upcomingSessions = + sessions.where((s) => s.status == ConsultationStatus.upcoming).toList(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 28), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 18), + Row( + children: [ + const Icon(Icons.event_available_rounded, color: AppColors.primary, size: 22), + const SizedBox(width: 8), + Text( + 'Jadwal Sesi Mendatang', + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Daftar janji konsultasi yang telah berhasil kamu booking.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 16), + if (upcomingSessions.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + const Icon(Icons.calendar_month_outlined, size: 36, color: Color(0xFF94A3B8)), + const SizedBox(height: 8), + Text( + 'Belum Ada Jadwal Aktif', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Pilih psikolog atau volunteer di bawah untuk membuat janji baru.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ) + else + ...upcomingSessions.map((session) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF3F4FE), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.primaryContainer), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 20, + backgroundImage: NetworkImage(session.psychologist.avatarUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + session.psychologist.typeLabel, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.successContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Terkonfirmasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.successDark, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + const Icon(Icons.access_time_rounded, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Text( + '${session.sessionDateTime.day}/${session.sessionDateTime.month}/${session.sessionDateTime.year} • ${_formatTime(session.sessionDateTime)} WIB', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + 'Keluhan: "${session.notes}"', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontStyle: FontStyle.italic, + color: AppColors.textSecondary, + ), + ), + ], + ], + ), + ); + }), + ], + ), + ); + }, + ); + } + + void _showConsultationHistoryModal(BuildContext context, WidgetRef ref) { + final sessions = ref.read(consultationSessionsProvider).value ?? []; + final completedSessions = + sessions.where((s) => s.status == ConsultationStatus.completed).toList(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 28), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 18), + Row( + children: [ + const Icon(Icons.history_rounded, color: Color(0xFF6366F1), size: 22), + const SizedBox(width: 8), + Text( + 'Riwayat Bicara & Konseling', + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Daftar psikolog dan volunteer yang pernah kamu ajak bicara.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 16), + if (completedSessions.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + const Icon(Icons.forum_outlined, size: 36, color: Color(0xFF94A3B8)), + const SizedBox(height: 8), + Text( + 'Belum Ada Riwayat Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Setelah sesi selesai, catatan refleksi dan diagnosis akan tersimpan di sini.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ) + else + ...completedSessions.map((session) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 20, + backgroundImage: NetworkImage(session.psychologist.avatarUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + session.psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + InkWell( + onTap: () { + Navigator.pop(ctx); + BookingConsultationSheet.show(context, session.psychologist); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Bicara Lagi', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ), + ], + ), + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'Catatan: ${session.notes}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ), + ], + ], + ), + ); + }), + ], + ), + ); + }, + ); + } + + void _showCareGuideModal(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 28), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 18), + Row( + children: [ + const Icon(Icons.menu_book_rounded, color: Color(0xFF0D9488), size: 22), + const SizedBox(width: 8), + Text( + 'Panduan Layanan Konseling', + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 14), + _buildGuideSection( + icon: Icons.verified_user_rounded, + title: 'Psikolog Profesional Independen', + desc: + 'Memiliki izin praktik resmi SIPP/STR. Cocok untuk asesmen klinis mendalam, depresi, kecemasan akut, trauma, dan psikoterapi terstruktur.', + ), + const SizedBox(height: 10), + _buildGuideSection( + icon: Icons.work_outline_rounded, + title: 'Psikolog Freelance', + desc: + 'Praktisi psikolog berlisensi yang fleksibel menangani konseling karir, quarter-life crisis, relasi hubungan, dan self-growth.', + ), + const SizedBox(height: 10), + _buildGuideSection( + icon: Icons.favorite_border_rounded, + title: 'Volunteer Konselor Sebaya', + desc: + 'Relawan terlatih Psychological First Aid (PFA). Menyediakan ruang dengar hangat dan curhat tanpa biaya untuk teman sebaya.', + ), + const SizedBox(height: 10), + _buildGuideSection( + icon: Icons.monetization_on_rounded, + title: 'Transaksi Koin EVA', + desc: + 'Seluruh sesi dapat dibayar menggunakan Koin EVA (atau transfer). Sesi volunteer gratis atau donasi koin sukarela.', + ), + ], + ), + ); + }, + ); + } + + Widget _buildGuideSection({ + required IconData icon, + required String title, + required String desc, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFFF0FDFA), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 16, color: const Color(0xFF0D9488)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + desc, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } + + String _formatTime(DateTime dt) { + final h = dt.hour.toString().padLeft(2, '0'); + final m = dt.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_card.dart new file mode 100644 index 0000000..c50c6c4 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_card.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/clinic_entity.dart'; + +class ClinicCard extends StatelessWidget { + final ClinicEntity clinic; + final VoidCallback onDetailsTap; + + const ClinicCard({ + super.key, + required this.clinic, + required this.onDetailsTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFEAEBFA)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Clinic Image & Distance Badge + Stack( + children: [ + Image.network( + clinic.photoUrl, + height: 120, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + height: 120, + color: AppColors.primaryContainer, + child: const Center( + child: Icon(Icons.local_hospital, color: AppColors.primary, size: 36), + ), + ), + ), + Positioned( + top: 10, + right: 10, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.near_me, color: Colors.white, size: 12), + const SizedBox(width: 4), + Text( + '${clinic.distanceKm} km', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + clinic.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Row( + children: [ + const Icon(Icons.star, size: 14, color: Color(0xFFF59E0B)), + const SizedBox(width: 3), + Text( + clinic.rating.toStringAsFixed(1), + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.location_on_outlined, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 4), + Expanded( + child: Text( + clinic.address, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Facilities Chips + Wrap( + spacing: 6, + runSpacing: 4, + children: clinic.facilities.take(3).map((facility) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + facility, + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + color: const Color(0xFF475569), + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 12), + + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: onDetailsTap, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + icon: const Icon(Icons.call_outlined, size: 15), + label: Text( + 'Hubungi & Reservasi Offline', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_location_map_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_location_map_card.dart new file mode 100644 index 0000000..c9fcaa3 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_location_map_card.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../theme/app_colors.dart'; + +/// Clean clinic location card with a direct button to open Google Maps. +/// Free from simulated canvas maps or mock drawing widgets. +class ClinicLocationMapCard extends StatelessWidget { + final String clinicName; + final String? address; + final double distanceKm; + final VoidCallback? onOpenMaps; + + const ClinicLocationMapCard({ + super.key, + required this.clinicName, + this.address, + this.distanceKm = 2.4, + this.onOpenMaps, + }); + + String get _effectiveAddress => + address ?? 'Jl. Kemanggisan Raya No. 45, Palmerah, Jakarta Barat'; + + /// Launches Google Maps with randomized realistic clinic location coordinates. + static Future<void> launchRandomGoogleMaps({ + BuildContext? context, + String? query, + }) async { + final randomTargets = [ + '-6.1952,106.7821', // Jakarta Barat + '-6.1754,106.8272', // Gambir, Jakarta Pusat + '-6.2297,106.8166', // Sudirman, Jakarta Selatan + '-6.1805,106.8284', // Menteng, Jakarta Pusat + '-6.2615,106.8106', // Kemang, Jakarta Selatan + ]; + final seed = DateTime.now().millisecondsSinceEpoch % randomTargets.length; + final selectedCoords = randomTargets[seed]; + final encodedQuery = query != null + ? Uri.encodeComponent('$query, Jakarta') + : selectedCoords; + + final url = Uri.parse('https://www.google.com/maps/search/?api=1&query=$encodedQuery'); + + try { + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } else if (context != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Membuka Google Maps ($encodedQuery)...'), + duration: const Duration(seconds: 2), + ), + ); + } + } catch (_) { + if (context != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Membuka rute di Google Maps ($encodedQuery)...'), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon( + Icons.storefront_rounded, + size: 16, + color: Color(0xFF0D9488), + ), + const SizedBox(width: 6), + Text( + 'Lokasi Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F766E), + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFCCFBF1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${distanceKm.toStringAsFixed(1)} km', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F766E), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + + // Clinic Name & Address + Text( + clinicName, + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + _effectiveAddress, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + height: 1.35, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/community_banner_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/community_banner_card.dart new file mode 100644 index 0000000..33d940d --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/community_banner_card.dart @@ -0,0 +1,392 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/community_status_entity.dart'; + +class CommunityBannerCard extends StatelessWidget { + final CommunityStatusEntity status; + final VoidCallback onOpenPaywall; + final VoidCallback onOpenDiscord; + + const CommunityBannerCard({ + super.key, + required this.status, + required this.onOpenPaywall, + required this.onOpenDiscord, + }); + + @override + Widget build(BuildContext context) { + if (!status.isMember) { + return _buildNonMemberBanner(context); + } else if (status.isVipActive) { + return _buildVipActiveBanner(context); + } else { + return _buildRegularMemberBanner(context); + } + } + + Widget _buildNonMemberBanner(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF5865F2), // Discord Blurple + Color(0xFF4752C4), + Color(0xFF353985), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5865F2).withValues(alpha: 0.3), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.people_alt, color: Colors.white, size: 14), + const SizedBox(width: 6), + Text( + 'KOMUNITAS SAFE SPACE', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFEF08A), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '1 Bln VIP Trial', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFF854D0E), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Ruang Aman untuk Saling Bercerita & Berproses Bersama', + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1.3, + ), + ), + const SizedBox(height: 6), + Text( + 'Sekali bayar untuk masuk server seumur hidup. Nikmati bonus 1 bulan gratis tiket live sharing session & webinar bersama psikolog.', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.white.withValues(alpha: 0.9), + height: 1.4, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Biaya Masuk', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: Colors.white.withValues(alpha: 0.75), + ), + ), + Text( + 'Rp 49.000', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ], + ), + const Spacer(), + ElevatedButton.icon( + onPressed: onOpenPaywall, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF4752C4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + icon: const Icon(Icons.star, size: 16, color: Color(0xFFEAB308)), + label: Text( + 'Gabung Sekarang', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildVipActiveBanner(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF6366F1), + Color(0xFF4F46E5), + Color(0xFF4338CA), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color(0xFF6366F1).withValues(alpha: 0.3), + blurRadius: 16, + offset: const Offset(0, 5), + ), + ], + ), + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFFDE047), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + children: [ + const Icon(Icons.verified, size: 14, color: Color(0xFF713F12)), + const SizedBox(width: 4), + Text( + 'VIP EVENT AKTIF', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFF713F12), + ), + ), + ], + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Sisa ${status.vipRemainingDays} Hari', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Kamu adalah Bagian dari Eva Safe Space', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + 'Tiket VIP Event aktif: kamu dapat bergabung di webinar mingguan dan room sharing bersama psikolog di Discord.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: onOpenDiscord, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white, width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon(Icons.forum_outlined, size: 18), + label: Text( + 'Buka Komunitas Discord', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildRegularMemberBanner(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Member Server Aktif', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + const Spacer(), + Text( + 'VIP Trial Berakhir', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textLight, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Ingin Ikut Live Webinar Psikolog Bulan Ini?', + style: GoogleFonts.plusJakartaSans( + fontSize: 14.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Akses server Discord tetap berlaku seumur hidup. Perpanjang VIP Event Pass untuk join sesi tanya jawab langsung.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onOpenDiscord, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textPrimary, + side: BorderSide(color: Colors.grey.shade300), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + 'Buka Discord', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: ElevatedButton( + onPressed: onOpenPaywall, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), + child: Text( + 'Perpanjang VIP', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/community_paywall_sheet.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/community_paywall_sheet.dart new file mode 100644 index 0000000..c1c36bf --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/community_paywall_sheet.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../providers/care_provider.dart'; + +class CommunityPaywallSheet extends ConsumerStatefulWidget { + const CommunityPaywallSheet({super.key}); + + static Future<void> show(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const CommunityPaywallSheet(), + ); + } + + @override + ConsumerState<CommunityPaywallSheet> createState() => _CommunityPaywallSheetState(); +} + +class _CommunityPaywallSheetState extends ConsumerState<CommunityPaywallSheet> { + bool _isLoading = false; + + Future<void> _handleJoin() async { + setState(() => _isLoading = true); + + final success = await ref.read(communityStatusProvider.notifier).joinWithVipTrial(); + + if (!mounted) return; + setState(() => _isLoading = false); + + if (success) { + Navigator.pop(context); + _showSuccessDialog(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Terjadi kendala saat memproses pendaftaran. Coba lagi.'), + backgroundColor: AppColors.error, + ), + ); + } + } + + void _showSuccessDialog() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + contentPadding: const EdgeInsets.all(24), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + color: Color(0xFFDCFCE7), + shape: BoxShape.circle, + ), + child: const Icon(Icons.check_circle, color: Color(0xFF16A34A), size: 38), + ), + const SizedBox(height: 16), + Text( + 'Selamat Bergabung!', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'Akses Server Komunitas dan 1 Bulan VIP Event Trial Anda telah aktif. Tautan undangan telah disiapkan.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.forum, color: Colors.white, size: 18), + label: Text( + 'Buka Discord Sekarang', + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 12, + bottom: MediaQuery.of(context).viewInsets.bottom + 28, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + + // Header Tag & Badge + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF5865F2).withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon(Icons.diversity_1, color: Color(0xFF5865F2), size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Eva Safe Space Community', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Komunitas Discord Pendukung Kesehatan Mental', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // Value Proposition Box + Container( + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.15)), + ), + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildBenefitRow( + icon: Icons.lock_clock, + title: 'Akses Sekali Bayar Seumur Hidup', + subtitle: 'Cukup bayar sekali untuk bergabung ke server obrolan komunitas tanpa langganan wajib.', + ), + const Divider(height: 20, color: Color(0x1A5358CB)), + _buildBenefitRow( + icon: Icons.workspace_premium, + title: 'Bonus 1 Bulan VIP Event Trial', + subtitle: 'Ikuti webinar mingguan interaktif, tanya jawab langsung dengan psikolog, & sharing session terfasilitasi.', + ), + const Divider(height: 20, color: Color(0x1A5358CB)), + _buildBenefitRow( + icon: Icons.shield, + title: 'Lingkungan Aman & Termoderasi', + subtitle: 'Bebas stigma dan di bawah supervisi konselor Eva untuk menjaga kehangatan safe space.', + ), + const Divider(height: 20, color: Color(0x1A5358CB)), + _buildBenefitRow( + icon: Icons.masks_outlined, + title: 'Privasi & Opsi Nama Samaran', + subtitle: 'Kamu bebas berekspresi secara anonim/pseudonim tanpa rasa cemas privasi.', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Price Summary Box + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tiket Masuk + 1 Bln VIP Pass', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + 'Rp 49.000', + style: GoogleFonts.plusJakartaSans( + fontSize: 20, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFDCFCE7), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Sekali Bayar', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF166534), + ), + ), + ), + ], + ), + ], + ), + const Spacer(), + const Icon(Icons.verified_user_outlined, color: AppColors.primary, size: 28), + ], + ), + ), + const SizedBox(height: 20), + + // Action Button + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleJoin, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Gabung Sekarang (Rp 49.000)', + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward, size: 18), + ], + ), + ), + ), + const SizedBox(height: 10), + Center( + child: Text( + 'Pembayaran aman via QRIS, GoPay, OVO, & Transfer Bank', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textLight, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildBenefitRow({ + required IconData icon, + required String title, + required String subtitle, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 18, color: AppColors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_history_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_history_card.dart new file mode 100644 index 0000000..54eb8ce --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_history_card.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/consultation_session_entity.dart'; + +class ConsultationHistoryCard extends StatelessWidget { + final ConsultationSessionEntity session; + final VoidCallback? onActionTap; + final VoidCallback? onRebookTap; + + const ConsultationHistoryCard({ + super.key, + required this.session, + this.onActionTap, + this.onRebookTap, + }); + + String _formatDateTime(DateTime dt) { + final months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', + 'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des' + ]; + final day = dt.day; + final month = months[dt.month - 1]; + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$day $month ${dt.year}, $hour:$minute WIB'; + } + + @override + Widget build(BuildContext context) { + final isUpcoming = session.isUpcoming; + + return Container( + decoration: BoxDecoration( + color: isUpcoming ? const Color(0xFFF0FDF4) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isUpcoming ? const Color(0xFFBBF7D0) : const Color(0xFFE2E8F0), + width: 1, + ), + ), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isUpcoming ? AppColors.successContainer : const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isUpcoming ? Icons.event_available : Icons.history, + size: 13, + color: isUpcoming ? AppColors.successDark : const Color(0xFF475569), + ), + const SizedBox(width: 4), + Text( + isUpcoming ? 'SESI MENDATANG' : 'SELESAI', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w800, + color: isUpcoming ? AppColors.successDark : const Color(0xFF475569), + ), + ), + ], + ), + ), + const Spacer(), + Text( + _formatDateTime(session.sessionDateTime), + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Doctor Info Row + Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: AppColors.primaryContainer, + backgroundImage: NetworkImage(session.psychologist.avatarUrl), + onBackgroundImageError: (exception, stackTrace) {}, + child: Text( + session.psychologist.name.substring(0, 2).toUpperCase(), + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + fontSize: 12, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + session.type == ConsultationType.onlineVideo + ? 'Sesi Konseling Video Call' + : 'Tatap Muka di ${session.locationName ?? "Klinik"}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: isUpcoming ? Colors.white : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isUpcoming ? AppColors.successContainer : const Color(0xFFE2E8F0), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.sticky_note_2_outlined, size: 14, color: AppColors.primary), + const SizedBox(width: 6), + Expanded( + child: Text( + session.notes!, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textPrimary, + height: 1.35, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 12), + + // Action Button + if (isUpcoming) ...[ + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: onActionTap, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(vertical: 8), + elevation: 0, + ), + icon: const Icon(Icons.video_call, size: 18), + label: Text( + 'Masuk Ruang Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ] else ...[ + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: onRebookTap, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + icon: const Icon(Icons.refresh, size: 15), + label: Text( + 'Konsultasi Lanjutan', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_result_bottom_sheet.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_result_bottom_sheet.dart new file mode 100644 index 0000000..6eda94f --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_result_bottom_sheet.dart @@ -0,0 +1,471 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/consultation_session_entity.dart'; + +/// Modal bottom sheet displaying detailed consultation results, clinical advice, +/// mood progression, and homework action items when a session finishes. +class ConsultationResultBottomSheet extends StatelessWidget { + final ConsultationSessionEntity session; + final VoidCallback? onClose; + + const ConsultationResultBottomSheet({ + super.key, + required this.session, + this.onClose, + }); + + static Future<void> show( + BuildContext context, + ConsultationSessionEntity session, { + VoidCallback? onClose, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => ConsultationResultBottomSheet( + session: session, + onClose: onClose, + ), + ); + } + + @override + Widget build(BuildContext context) { + final advice = session.adviceList.isNotEmpty + ? session.adviceList + : const [ + 'Lakukan relaksasi napas diafragma 4-7-8 setiap pagi dan 15 menit sebelum tidur.', + 'Terapkan teknik restrukturisasi kognitif di Eva Diary saat mulai overthinking.', + 'Luangkan jeda hening (mindful pause) 3-5 menit di sela aktivitas kerja.', + ]; + + final actionItems = session.actionItems.isNotEmpty + ? session.actionItems + : const [ + 'Tuliskan 3 bukti objektif pencapaian dan hal yang disyukuri setiap malam.', + 'Latih penetapan batasan tegas (boundary setting) dalam komunikasi profesional.', + ]; + + final summary = session.sessionSummary ?? + 'Sesi konsultasi tatap muka selesai dilaksanakan dengan baik. Pasien menunjukkan keterbukaan dalam mengidentifikasi pemicu stres dan memiliki rencana aksi mandiri yang jelas.'; + + return Container( + height: MediaQuery.of(context).size.height * 0.88, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // Drag Handle + const SizedBox(height: 12), + Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFCBD5E1), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: const BoxDecoration( + color: Color(0xFFDCFCE7), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_circle_rounded, + color: Color(0xFF16A34A), + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil & Saran Sesi Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + '${session.psychologist.name} • ${session.psychologist.title}', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, color: AppColors.textLight), + onPressed: () { + Navigator.pop(context); + if (onClose != null) onClose!(); + }, + ), + ], + ), + ), + + const SizedBox(height: 10), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + + // Scrollable Content + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Session Metric Tag + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Sesi #${session.sessionNumber} Selesai', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Durasi: ${session.durationMinutes} Menit', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // 1. Rangkuman Hasil Konsultasi (Summary) + Text( + 'RANGKUMAN HASIL KONSULTASI', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.format_quote_rounded, + size: 20, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + summary, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 18), + + // 2. Perubahan Suasana Hati / Mood Transition + Text( + 'EVALUASI KONDISI EMOSI PASIEN', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sebelum Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + session.moodBefore ?? 'Cemas Tinggi (8/10)', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFFEF4444), + ), + ), + ], + ), + ), + const Icon( + Icons.arrow_forward_rounded, + size: 18, + color: Color(0xFF94A3B8), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Sesudah Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + session.moodAfter ?? 'Lebih Tenang (4/10)', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF16A34A), + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 18), + + // 3. Saran & Rekomendasi Klinis (Advice List) + Row( + children: [ + const Icon( + Icons.lightbulb_outline_rounded, + size: 16, + color: Color(0xFFF59E0B), + ), + const SizedBox(width: 6), + Text( + 'SARAN & REKOMENDASI PSIKOLOG', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 8), + Column( + children: List.generate(advice.length, (idx) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFDE68A)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 20, + height: 20, + decoration: const BoxDecoration( + color: Color(0xFFF59E0B), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${idx + 1}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + advice[idx], + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: const Color(0xFF78350F), + height: 1.35, + ), + ), + ), + ], + ), + ); + }), + ), + + const SizedBox(height: 14), + + // 4. Latihan Mandiri (Action Items) + Row( + children: [ + const Icon( + Icons.check_circle_outline_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 6), + Text( + 'LATIHAN MANDIRI (ACTION ITEMS)', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 8), + Column( + children: actionItems.map((item) { + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: Row( + children: [ + const Icon( + Icons.task_alt_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: const Color(0xFF3730A3), + ), + ), + ), + ], + ), + ); + }).toList(), + ), + + const SizedBox(height: 24), + ], + ), + ), + ), + + // Bottom Action Button + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Color(0xFFF1F5F9))), + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: () { + Navigator.pop(context); + if (onClose != null) onClose!(); + }, + child: Text( + 'Selesai & Tutup Saran Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/dense_review_tags_wrap.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/dense_review_tags_wrap.dart new file mode 100644 index 0000000..90fbd89 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/dense_review_tags_wrap.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../domain/entities/psychologist_entity.dart'; + +/// Reusable dense bin-packing review tags widget. +/// Guarantees at most [maxRows] lines. Intelligently selects combinations of tags +/// that fit within the available width. If a tag does not fit comfortably in the +/// remaining space, it is simply omitted ("tidak usah ditampilkan") to ensure zero overflow. +class DenseReviewTagsWrap extends StatelessWidget { + final List<PatientReviewTag> tags; + final int maxRows; + final double fontSize; + final double iconSize; + final EdgeInsets padding; + final double spacing; + final double runSpacing; + + const DenseReviewTagsWrap({ + super.key, + required this.tags, + this.maxRows = 2, + this.fontSize = 10.5, + this.iconSize = 11, + this.padding = const EdgeInsets.symmetric(horizontal: 7.5, vertical: 3.5), + this.spacing = 6, + this.runSpacing = 6, + }); + + @override + Widget build(BuildContext context) { + if (tags.isEmpty) return const SizedBox.shrink(); + + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + if (maxWidth <= 0 || maxWidth.isInfinite) { + return const SizedBox.shrink(); + } + + final textScaler = MediaQuery.textScalerOf(context); + final textStyle = GoogleFonts.plusJakartaSans( + fontSize: fontSize, + fontWeight: FontWeight.w600, + ); + + // Measure each tag width with actual device TextScaler + safety cushion + final tagWidths = <PatientReviewTag, double>{}; + for (final tag in tags) { + final tp = TextPainter( + text: TextSpan(text: tag.label, style: textStyle), + textDirection: TextDirection.ltr, + textScaler: textScaler, + maxLines: 1, + )..layout(); + // Total conservative width: text + icon + gap + horizontal padding + border + safe buffer + tagWidths[tag] = tp.width + iconSize + 5.0 + padding.horizontal + 2.0 + 6.0; + } + + // Greedy knapsack bin-packing: pack candidates into up to maxRows + // Strictly omit any tag that cannot fit completely within the line budget + final remaining = List<PatientReviewTag>.of(tags); + final rows = <List<PatientReviewTag>>[]; + + for (int r = 0; r < maxRows; r++) { + if (remaining.isEmpty) break; + + final currentRow = <PatientReviewTag>[]; + double currentWidth = 0.0; + + while (remaining.isNotEmpty) { + final spaceLeft = currentRow.isEmpty + ? maxWidth + : (maxWidth - currentWidth - spacing); + + if (spaceLeft <= 0) break; + + // Find best candidate that fits within spaceLeft + PatientReviewTag? bestCandidate; + double bestCandidateWidth = 0.0; + + for (final candidate in remaining) { + final w = tagWidths[candidate] ?? 70.0; + if (w <= spaceLeft) { + // Pick candidate that fills the line most densely + if (bestCandidate == null || w > bestCandidateWidth) { + bestCandidate = candidate; + bestCandidateWidth = w; + } + } + } + + if (bestCandidate != null) { + currentRow.add(bestCandidate); + currentWidth += (currentRow.length == 1 + ? bestCandidateWidth + : bestCandidateWidth + spacing); + remaining.remove(bestCandidate); + } else { + // If no candidate fits into the remaining space, stop this row! + // Do NOT force any candidate into the row. + break; + } + } + + if (currentRow.isNotEmpty) { + rows.add(currentRow); + } else { + // Even on an empty row, no remaining tag fits in maxWidth. + // Do not display any further tags. + break; + } + } + + if (rows.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: rows.asMap().entries.map((entry) { + final rowIndex = entry.key; + final rowTags = entry.value; + final isLastRow = rowIndex == rows.length - 1; + + return Padding( + padding: EdgeInsets.only(bottom: isLastRow ? 0 : runSpacing), + child: Wrap( + spacing: spacing, + runSpacing: runSpacing, + children: rowTags.map((tag) { + final isPos = tag.isPositive; + final bgColor = isPos ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2); + final borderColor = isPos ? const Color(0xFFBBF7D0) : const Color(0xFFFECACA); + final textColor = isPos ? const Color(0xFF15803D) : const Color(0xFFB91C1C); + final iconColor = isPos ? const Color(0xFF16A34A) : const Color(0xFFDC2626); + final icon = isPos ? Icons.thumb_up_alt_rounded : Icons.thumb_down_alt_rounded; + + return Container( + padding: padding, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: iconSize, color: iconColor), + const SizedBox(width: 4.5), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth > 40 ? maxWidth - iconSize - 20 : 120), + child: Text( + tag.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: fontSize, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ); + }).toList(), + ); + }, + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_card.dart new file mode 100644 index 0000000..cf6bc19 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_card.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import 'dense_review_tags_wrap.dart'; + +class PsychologistCard extends StatelessWidget { + final PsychologistEntity psychologist; + final VoidCallback onBookTap; + final VoidCallback? onTap; + + const PsychologistCard({ + super.key, + required this.psychologist, + required this.onBookTap, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFEAEBFA)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.05), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar with Online indicator + Stack( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: AppColors.primaryContainer, + backgroundImage: NetworkImage(psychologist.avatarUrl), + onBackgroundImageError: (exception, stackTrace) {}, + child: Text( + psychologist.name.substring(0, 2).toUpperCase(), + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + if (psychologist.availableOnline) + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 13, + height: 13, + decoration: BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 14), + + // Name, Practitioner Badge, Title & Rating + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (psychologist.isVolunteer) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFF0F9FF), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: const Color(0xFFBAE6FD)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.volunteer_activism_rounded, + size: 11, + color: Color(0xFF0EA5E9), + ), + const SizedBox(width: 4), + Text( + 'Relawan', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF0369A1), + ), + ), + ], + ), + ), + if (psychologist.hasPsychologistCertificate) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.school_rounded, + size: 11, + color: Color(0xFF6366F1), + ), + const SizedBox(width: 4), + Text( + 'Profesional', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF3730A3), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 14.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.star_rounded, size: 15, color: Color(0xFFF59E0B)), + const SizedBox(width: 3), + Text( + '${psychologist.rating.toStringAsFixed(1)} (${psychologist.reviewCount})', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + '• ${psychologist.experienceYears} Thn Pengalaman', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + // Patient Review Tags (Intelligent 2-row bin-packing with positive & negative sentiment) + if (psychologist.effectiveReviewTags.isNotEmpty) ...[ + DenseReviewTagsWrap( + tags: psychologist.effectiveReviewTags, + maxRows: 2, + ), + const SizedBox(height: 12), + ], + + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 12), + + // Pricing & Booking Action + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Biaya Sesi (${psychologist.durationMinutes} mnt)', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 3), + Row( + children: [ + if (psychologist.coinsPerSession > 0) ...[ + Container( + padding: const EdgeInsets.all(3), + decoration: const BoxDecoration( + color: Color(0xFFFEF3C7), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.monetization_on_rounded, + size: 14, + color: Color(0xFFD97706), + ), + ), + const SizedBox(width: 5), + ], + Text( + psychologist.coinsPerSession > 0 + ? '${psychologist.coinsPerSession} Koin' + : 'Gratis', + style: GoogleFonts.plusJakartaSans( + fontSize: 15.5, + fontWeight: FontWeight.w800, + color: psychologist.coinsPerSession > 0 + ? AppColors.textPrimary + : const Color(0xFF0D9488), + ), + ), + if (psychologist.coinsPerSession > 0) + Text( + ' / sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: onBookTap, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), + child: Text( + 'Jadwalkan', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_certificate_carousel.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_certificate_carousel.dart new file mode 100644 index 0000000..77d10dd --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_certificate_carousel.dart @@ -0,0 +1,472 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/psychologist_entity.dart'; + +/// Clean certificate carousel displaying the certificate photo and label directly. +/// Free from artificial marketing slogans or redundant placeholder labels. +class PsychologistCertificateCarousel extends StatefulWidget { + final List<CertificateItem>? certificateItems; + final List<String>? certifications; + + const PsychologistCertificateCarousel({ + super.key, + this.certificateItems, + this.certifications, + }); + + @override + State<PsychologistCertificateCarousel> createState() => + _PsychologistCertificateCarouselState(); +} + +class _PsychologistCertificateCarouselState + extends State<PsychologistCertificateCarousel> { + late final PageController _pageController; + int _currentIndex = 0; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + List<CertificateItem> get _items { + if (widget.certificateItems != null && widget.certificateItems!.isNotEmpty) { + return widget.certificateItems!; + } + final certTitles = widget.certifications ?? []; + return certTitles.map((title) { + final lower = title.toLowerCase(); + String url; + if (lower.contains('sipp')) { + url = 'https://images.unsplash.com/photo-1614036417651-efe5912149d8?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('magister') || lower.contains('s2')) { + url = 'https://images.unsplash.com/photo-1659080928170-b9924d616f04?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('cbt') || lower.contains('sfbt') || lower.contains('mbsr')) { + url = 'https://images.unsplash.com/photo-1659080907100-23f0dac0fe27?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('pfa') || lower.contains('first aid')) { + url = 'https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('peer') || lower.contains('sebaya') || lower.contains('safe space')) { + url = 'https://images.unsplash.com/photo-1450133064473-71024230f91b?w=800&auto=format&fit=crop&q=80'; + } else { + url = 'https://images.unsplash.com/photo-1523240795612-9a054b0db644?w=800&auto=format&fit=crop&q=80'; + } + return CertificateItem(title: title, imageUrl: url); + }).toList(); + } + + void _onPageChanged(int index) { + setState(() { + _currentIndex = index; + }); + } + + void _goToPrevious() { + if (_currentIndex > 0) { + _pageController.previousPage( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + } + + void _goToNext(int total) { + if (_currentIndex < total - 1) { + _pageController.nextPage( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + } + + void _showFullScreenPreview(BuildContext context, String imageUrl, String title) { + showDialog( + context: context, + barrierColor: Colors.black87, + builder: (ctx) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + title, + style: GoogleFonts.plusJakartaSans( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, color: Colors.white, size: 24), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: InteractiveViewer( + maxScale: 4.0, + child: Image.network( + imageUrl, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) => Container( + height: 260, + color: Colors.white, + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + title, + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Cubit / geser untuk memperbesar foto', + style: GoogleFonts.plusJakartaSans( + color: Colors.white70, + fontSize: 11, + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final items = _items; + + if (items.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + const Icon( + Icons.info_outline_rounded, + size: 18, + color: AppColors.textLight, + ), + const SizedBox(width: 8), + Text( + 'Belum ada data sertifikasi.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon( + Icons.workspace_premium_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 6), + Text( + 'Sertifikat & Lisensi', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${_currentIndex + 1} / ${items.length}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Carousel Container (PageView) + SizedBox( + height: 220, + child: PageView.builder( + controller: _pageController, + physics: const BouncingScrollPhysics(), + itemCount: items.length, + onPageChanged: _onPageChanged, + itemBuilder: (context, index) { + final cert = items[index]; + return _buildPhotoAndLabelCard(cert); + }, + ), + ), + + const SizedBox(height: 10), + + // Navigation Bar: Chevron Left, Dots, Chevron Right + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Left Chevron Arrow + InkWell( + onTap: _currentIndex > 0 ? _goToPrevious : null, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: _currentIndex > 0 + ? const Color(0xFFF1F5F9) + : const Color(0xFFF8FAFC), + shape: BoxShape.circle, + border: Border.all( + color: _currentIndex > 0 + ? const Color(0xFFCBD5E1) + : const Color(0xFFE2E8F0), + ), + ), + child: Icon( + Icons.chevron_left_rounded, + size: 18, + color: _currentIndex > 0 + ? AppColors.textPrimary + : AppColors.textLight.withValues(alpha: 0.35), + ), + ), + ), + + // Dot Indicators + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(items.length, (dotIdx) { + final isActive = dotIdx == _currentIndex; + return GestureDetector( + onTap: () { + _pageController.animateToPage( + dotIdx, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: isActive ? 18 : 6, + height: 6, + decoration: BoxDecoration( + color: isActive + ? AppColors.primary + : const Color(0xFFCBD5E1), + borderRadius: BorderRadius.circular(3), + ), + ), + ); + }), + ), + + // Right Chevron Arrow + InkWell( + onTap: _currentIndex < items.length - 1 ? () => _goToNext(items.length) : null, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: _currentIndex < items.length - 1 + ? const Color(0xFFF1F5F9) + : const Color(0xFFF8FAFC), + shape: BoxShape.circle, + border: Border.all( + color: _currentIndex < items.length - 1 + ? const Color(0xFFCBD5E1) + : const Color(0xFFE2E8F0), + ), + ), + child: Icon( + Icons.chevron_right_rounded, + size: 18, + color: _currentIndex < items.length - 1 + ? AppColors.textPrimary + : AppColors.textLight.withValues(alpha: 0.35), + ), + ), + ), + ], + ), + ], + ); + } + + Widget _buildPhotoAndLabelCard(CertificateItem cert) { + return Container( + width: double.infinity, + 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: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Foto Sertifikat + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(13)), + child: GestureDetector( + onTap: () => _showFullScreenPreview(context, cert.imageUrl, cert.title), + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + cert.imageUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: const Color(0xFFF8FAFC), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.workspace_premium_rounded, + size: 36, + color: Color(0xFF94A3B8), + ), + const SizedBox(height: 6), + Text( + 'Dokumen Sertifikat', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + color: const Color(0xFFF8FAFC), + child: const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + }, + ), + + // Tap to zoom hint overlay + Positioned( + bottom: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3.5), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.zoom_in_rounded, + size: 13, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + 'Perbesar', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + + // 2. Label Terkait Sertifikat + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Text( + cert.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + height: 1.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_role_badge.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_role_badge.dart new file mode 100644 index 0000000..de10477 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_role_badge.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/psychologist_entity.dart'; + +/// Reusable credential & role badge representing Volunteer and/or Certified Psychologist. +/// Aligned with the portal & mobile shield medallion design system. +class PsychologistRoleBadge extends StatelessWidget { + final PsychologistEntity psychologist; + final bool showExplanationOnTap; + + const PsychologistRoleBadge({ + super.key, + required this.psychologist, + this.showExplanationOnTap = true, + }); + + void _showQualificationModal(BuildContext context) { + if (!showExplanationOnTap) return; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + + // Header Row + Row( + children: [ + _buildMedallionIcons(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + psychologist.badgeHeaderTitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + _buildBadgeChips(), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 14), + + // Layer Explanations + Text( + 'Tingkat Kualifikasi & Kredensial', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + + if (psychologist.isVolunteer) ...[ + _buildLayerItem( + number: '1', + title: 'Relawan', + description: psychologist.hasPsychologistCertificate + ? 'Aktif memberikan sesi curhat & pendampingan sebaya bebas biaya (Gratis) untuk masyarakat.' + : 'Relawan terlatih di bidang Psychological First Aid (PFA) & konseling sebaya. Bebas biaya / Gratis.', + bgColor: const Color(0xFF0EA5E9), + ), + const SizedBox(height: 10), + ], + + if (psychologist.hasPsychologistCertificate) ...[ + _buildLayerItem( + number: psychologist.isVolunteer ? '2' : '1', + title: 'Profesional', + description: + 'Lulusan Magister Profesi Psikologi (S2) dengan Surat Izin Praktik Psikologi (SIPP) resmi: ${psychologist.registrationNumber}.', + bgColor: const Color(0xFF6366F1), + ), + const SizedBox(height: 10), + ], + + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 11), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + 'Mengerti', + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLayerItem({ + required String number, + required String title, + required String description, + required Color bgColor, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 20, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: bgColor, + shape: BoxShape.circle, + ), + child: Text( + number, + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildMedallionIcons() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (psychologist.isVolunteer) + Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + color: Color(0xFF0EA5E9), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Color(0x330EA5E9), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.volunteer_activism_rounded, + color: Colors.white, + size: 15, + ), + ), + if (psychologist.hasBothBadges) const SizedBox(width: 5), + if (psychologist.hasPsychologistCertificate) + Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF6366F1), Color(0xFF4338CA)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Color(0x336366F1), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.school_rounded, + color: Colors.white, + size: 15, + ), + ), + ], + ); + } + + Widget _buildBadgeChips() { + return Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (psychologist.isVolunteer) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + 'Relawan', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w600, + color: const Color(0xFF64748B), + ), + ), + ), + if (psychologist.hasPsychologistCertificate) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + 'Profesional', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF3730A3), + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: showExplanationOnTap ? () => _showQualificationModal(context) : null, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + _buildMedallionIcons(), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + psychologist.badgeHeaderTitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w800, + color: const Color(0xFF1E293B), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (showExplanationOnTap) + const Icon( + Icons.info_outline_rounded, + size: 13, + color: Color(0xFF94A3B8), + ), + ], + ), + const SizedBox(height: 4), + _buildBadgeChips(), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/chat/data/datasources/chat_datasource.dart b/apps/eva_mobile/lib/features/chat/data/datasources/chat_datasource.dart index ab86969..baafa15 100644 --- a/apps/eva_mobile/lib/features/chat/data/datasources/chat_datasource.dart +++ b/apps/eva_mobile/lib/features/chat/data/datasources/chat_datasource.dart @@ -5,4 +5,5 @@ abstract class ChatDataSource { Future<List<ConversationModel>> getConversations(); Future<ConversationModel?> getConversationById(String id); Future<ChatMessageModel> sendMessage(String conversationId, String text); + Stream<String> streamConversationSummary(String conversationId); } diff --git a/apps/eva_mobile/lib/features/chat/data/datasources/mock_chat_datasource.dart b/apps/eva_mobile/lib/features/chat/data/datasources/mock_chat_datasource.dart index 682f8a9..2c269e8 100644 --- a/apps/eva_mobile/lib/features/chat/data/datasources/mock_chat_datasource.dart +++ b/apps/eva_mobile/lib/features/chat/data/datasources/mock_chat_datasource.dart @@ -111,4 +111,14 @@ class MockChatDataSource implements ChatDataSource { return userMsg; } + + @override + Stream<String> streamConversationSummary(String conversationId) async* { + await Future.delayed(const Duration(milliseconds: 300)); + const sample = 'Sesi percakapan curhat bersama EVA berjalan dengan hangat dan memberikan ruang aman untuk refleksi batin.'; + for (final word in sample.split(' ')) { + await Future.delayed(const Duration(milliseconds: 60)); + yield '$word '; + } + } } diff --git a/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart b/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart index 8c7fa6f..e205c4c 100644 --- a/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart +++ b/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart @@ -1,6 +1,10 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; import 'chat_datasource.dart'; import '../models/conversation_model.dart'; import '../models/chat_message_model.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../../../core/network/api_client.dart'; class RemoteChatDataSource implements ChatDataSource { @@ -37,4 +41,40 @@ class RemoteChatDataSource implements ChatDataSource { }); return ChatMessageModel.fromJson(response); } + + @override + Stream<String> streamConversationSummary(String conversationId) async* { + final headers = await AppConfig.getAuthHeaders(); + final url = Uri.parse('${AppConfig.baseUrl}/conversations/$conversationId/summary/stream'); + final request = http.Request('GET', url); + request.headers.addAll(headers); + + final client = http.Client(); + try { + final streamedResponse = await client.send(request).timeout(const Duration(seconds: 25)); + if (streamedResponse.statusCode != 200) { + throw ApiException('Failed to stream summary', statusCode: streamedResponse.statusCode); + } + + final stream = streamedResponse.stream + .transform(utf8.decoder) + .transform(const LineSplitter()); + + await for (final line in stream) { + final trimmed = line.trim(); + if (trimmed.startsWith('data:')) { + final jsonStr = trimmed.substring(5).trim(); + if (jsonStr.isEmpty) continue; + try { + final payload = jsonDecode(jsonStr); + if (payload is Map && payload.containsKey('token')) { + yield payload['token']?.toString() ?? ''; + } + } catch (_) {} + } + } + } finally { + client.close(); + } + } } diff --git a/apps/eva_mobile/lib/features/chat/data/repositories/chat_repository_impl.dart b/apps/eva_mobile/lib/features/chat/data/repositories/chat_repository_impl.dart index 6257915..5dbf552 100644 --- a/apps/eva_mobile/lib/features/chat/data/repositories/chat_repository_impl.dart +++ b/apps/eva_mobile/lib/features/chat/data/repositories/chat_repository_impl.dart @@ -16,4 +16,8 @@ class ChatRepositoryImpl implements ChatRepository { @override Future<ChatMessageEntity> sendMessage(String conversationId, String text) => _dataSource.sendMessage(conversationId, text); + + @override + Stream<String> streamConversationSummary(String conversationId) => + _dataSource.streamConversationSummary(conversationId); } diff --git a/apps/eva_mobile/lib/features/chat/data/repositories/fallback_chat_repository.dart b/apps/eva_mobile/lib/features/chat/data/repositories/fallback_chat_repository.dart new file mode 100644 index 0000000..4aef710 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/repositories/fallback_chat_repository.dart @@ -0,0 +1,51 @@ +import '../../domain/repositories/chat_repository.dart'; +import '../../domain/entities/conversation_entity.dart'; +import '../../domain/entities/chat_message_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackChatRepository implements ChatRepository { + final ChatRepository _remote; + final ChatRepository _mock; + + FallbackChatRepository({ + required ChatRepository remote, + required ChatRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future<List<ConversationEntity>> getConversations() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getConversations(), + mockCall: () => _mock.getConversations(), + operationName: 'getConversations', + ); + } + + @override + Future<ConversationEntity?> getConversationById(String id) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getConversationById(id), + mockCall: () => _mock.getConversationById(id), + operationName: 'getConversationById($id)', + ); + } + + @override + Future<ChatMessageEntity> sendMessage(String conversationId, String text) { + return FallbackExecutor.execute( + remoteCall: () => _remote.sendMessage(conversationId, text), + mockCall: () => _mock.sendMessage(conversationId, text), + operationName: 'sendMessage', + ); + } + + @override + Stream<String> streamConversationSummary(String conversationId) { + return FallbackExecutor.executeStream( + remoteStreamCall: () => _remote.streamConversationSummary(conversationId), + mockStreamCall: () => _mock.streamConversationSummary(conversationId), + operationName: 'streamConversationSummary($conversationId)', + ); + } +} diff --git a/apps/eva_mobile/lib/features/chat/domain/repositories/chat_repository.dart b/apps/eva_mobile/lib/features/chat/domain/repositories/chat_repository.dart index 8372f24..729c125 100644 --- a/apps/eva_mobile/lib/features/chat/domain/repositories/chat_repository.dart +++ b/apps/eva_mobile/lib/features/chat/domain/repositories/chat_repository.dart @@ -5,4 +5,5 @@ abstract class ChatRepository { Future<List<ConversationEntity>> getConversations(); Future<ConversationEntity?> getConversationById(String id); Future<ChatMessageEntity> sendMessage(String conversationId, String text); + Stream<String> streamConversationSummary(String conversationId); } diff --git a/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart b/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart index a68fcd2..585f230 100644 --- a/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart +++ b/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart @@ -1,16 +1,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/chat_repository.dart'; import '../../domain/entities/conversation_entity.dart'; import '../../data/datasources/mock_chat_datasource.dart'; import '../../data/datasources/remote_chat_datasource.dart'; import '../../data/repositories/chat_repository_impl.dart'; +import '../../data/repositories/fallback_chat_repository.dart'; final chatRepositoryProvider = Provider<ChatRepository>((ref) { + final mockRepo = ChatRepositoryImpl(MockChatDataSource()); + final remoteRepo = ChatRepositoryImpl(RemoteChatDataSource()); + if (AppConfig.useMockData) { - return ChatRepositoryImpl(MockChatDataSource()); + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackChatRepository(remote: remoteRepo, mock: mockRepo); } else { - return ChatRepositoryImpl(RemoteChatDataSource()); + return remoteRepo; } }); diff --git a/apps/eva_mobile/lib/features/chat/presentation/screens/ai_conversation_screen.dart b/apps/eva_mobile/lib/features/chat/presentation/screens/ai_conversation_screen.dart new file mode 100644 index 0000000..4cb9264 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/presentation/screens/ai_conversation_screen.dart @@ -0,0 +1,670 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/chat/presentation/widgets/animated_session_card.dart'; +import 'package:eva_mobile/features/chat/presentation/widgets/session_list_skeleton.dart'; +import 'package:eva_mobile/features/coin/presentation/providers/coin_provider.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/presentation/providers/tutorial_provider.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; +import '../providers/chat_provider.dart'; + +class AiConversationScreen extends ConsumerStatefulWidget { + const AiConversationScreen({super.key}); + + @override + ConsumerState<AiConversationScreen> createState() => _AiConversationScreenState(); +} + +class _AiConversationScreenState extends ConsumerState<AiConversationScreen> { + final ScrollController _scrollController = ScrollController(); + final GlobalKey _coinBadgeKey = GlobalKey(debugLabel: 'coinBadge'); + final GlobalKey _startVoiceButtonKey = GlobalKey(debugLabel: 'startVoiceButton'); + List<Map<String, dynamic>> _conversations = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _fetchConversations(isRefresh: true); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future<void> _fetchConversations({bool isRefresh = false}) async { + if (isRefresh) { + setState(() => _isLoading = true); + } + + try { + final entities = await ref.read(chatRepositoryProvider).getConversations(); + final items = entities.map((c) => <String, dynamic>{ + 'id': c.id, + 'title': c.title, + 'date': c.date, + 'lastMessageTime': c.lastSessionTime, + 'dominant_emoji': c.moodEmoji, + 'dominant_emotion': c.moodTag, + 'summary': c.summary, + 'messages': c.messages.map((m) => <String, dynamic>{ + 'id': m.id, + 'sender': m.isUser ? 'user' : 'ai', + 'text': m.text, + 'time': m.time, + 'emotionEmoji': m.emotionEmoji, + }).toList(), + }).toList(); + + if (mounted) { + setState(() => _conversations = items); + } + } catch (_) {} finally { + if (mounted) setState(() => _isLoading = false); + } + } + + static ({String tag, String emoji}) _detectEmotionFallback(String text) { + final tLow = text.toLowerCase(); + const anxWords = ['cemas', 'takut', 'nervous', 'panggung', 'khawatir', 'panik', 'deg-degan', 'gugup', 'takutnya', 'bingung', 'was-was', 'tegang']; + const depWords = ['sedih', 'nangis', 'menangis', 'hampa', 'sendiri', 'kehilangan', 'terpuruk', 'putus asa', 'kecewa', 'patah hati', 'bunuh diri']; + const strWords = ['stres', 'capek', 'lelah', 'beban', 'berat', 'penat', 'pusing', 'mumet', 'tekanan', 'tugas', 'deadline', 'kerjaan', 'letih']; + const angWords = ['marah', 'kesal', 'jengkel', 'benci', 'emosi', 'sebal', 'kesel', 'geram', 'murka']; + const posWords = ['lega', 'senang', 'bahagia', 'terima kasih', 'makasih', 'enakan', 'tenang', 'nyaman', 'santai', 'alhamdulillah', 'syukurlah', 'baik', 'bersyukur']; + + if (anxWords.any((w) => tLow.contains(w))) { + return (tag: 'fear (78%)', emoji: '😨'); + } + if (depWords.any((w) => tLow.contains(w))) { + return (tag: 'sadness (82%)', emoji: '😔'); + } + if (strWords.any((w) => tLow.contains(w))) { + return (tag: 'stress (75%)', emoji: '💥'); + } + if (angWords.any((w) => tLow.contains(w))) { + return (tag: 'anger (70%)', emoji: '😡'); + } + if (posWords.any((w) => tLow.contains(w))) { + return (tag: 'calm (85%)', emoji: '😌'); + } + return (tag: 'netral (60%)', emoji: '😐'); + } + + Future<void> _handleStartVoiceSession() async { + final tutorialState = ref.read(tutorialNotifierProvider); + if (tutorialState.isActive && + tutorialState.currentStep == TutorialStep.voiceStart) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceStart); + } + await Navigator.pushNamed(context, '/voice_call'); + if (mounted) { + _fetchConversations(isRefresh: true); + } + } + + Widget _buildCoinBadge(int coinBalance) { + return Material( + color: Colors.transparent, + child: InkWell( + key: _coinBadgeKey, + borderRadius: BorderRadius.circular(20), + onTap: () { + final tutorialState = ref.read(tutorialNotifierProvider); + if (tutorialState.isActive && + tutorialState.currentStep == TutorialStep.voiceCoins) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceCoins); + return; + } + // TODO: Navigasi ke page pembayaran setelah page pembayaran selesai dibangun + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.15), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Coin Icon with '+' badge + Stack( + clipBehavior: Clip.none, + children: [ + Container( + padding: const EdgeInsets.all(2.5), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Color(0xFFFFD54F), + Color(0xFFFFB300), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: const Icon( + Icons.monetization_on_rounded, + color: Colors.white, + size: 15, + ), + ), + Positioned( + right: -3, + bottom: -3, + child: Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.2), + ), + child: const Icon( + Icons.add, + size: 8, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(width: 8), + Text( + '$coinBalance', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.primary, + letterSpacing: -0.2, + ), + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final tutorialState = ref.watch(tutorialNotifierProvider); + final coinBalance = ref.watch(coinBalanceProvider); + + final bool showTutorialOverlay = tutorialState.isActive && + (tutorialState.currentStep == TutorialStep.voiceCoins || + tutorialState.currentStep == TutorialStep.voiceStart); + + return Scaffold( + body: Stack( + children: [ + Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Column( + children: [ + // Header Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Text( + 'Sesi Suara EVA', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + const Spacer(), + _buildCoinBadge(coinBalance), + ], + ), + ), + const Divider(height: 1, color: Color(0xFFEBECEF)), + + + + // Scrollable Content Body with Pull-to-Refresh & Infinite Scroll + Expanded( + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: () => _fetchConversations(isRefresh: true), + child: SingleChildScrollView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Column( + + children: [ + + const SizedBox(height: 12), + + + + // 3D Glowing Iridescent Orb Visual + StaggeredEntrance( + index: 0, + child: Center( + child: Container( + width: 180, + height: 180, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const SweepGradient( + colors: [ + Color(0xFFFFB6C1), + Color(0xFFE2DAFF), + Color(0xFFA7E6FF), + Color(0xFF8B93FF), + Color(0xFFFFB6C1), + ], + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.3), + blurRadius: 40, + spreadRadius: 4, + ), + ], + ), + child: Container( + margin: const EdgeInsets.all(3), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + Colors.white, + Color(0xFFEADBFF), + Color(0xFFA7E6FF), + ], + center: Alignment(-0.3, -0.3), + radius: 0.8, + ), + ), + child: const Center( + child: Icon( + Icons.graphic_eq, + size: 48, + color: AppColors.primary, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 28), + + // Title & Subtitle Greeting + StaggeredEntrance( + index: 1, + child: Column( + children: [ + Text( + 'EVA Siap Mendengarkan', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'Bicara secara alami kapan saja tanpa mengetik. EVA hadir mendampingi dan mendengarkan perasaanmu.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 32), + + // Main Action Button: Mulai Sesi Suara + StaggeredEntrance( + index: 2, + child: CustomPillButton( + key: _startVoiceButtonKey, + text: 'Mulai Sesi Suara', + suffixIcon: Icons.mic, + onPressed: () => _handleStartVoiceSession(), + ), + ), + const SizedBox(height: 36), + + // Recent Voice Sessions Section + StaggeredEntrance( + index: 3, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'RIWAYAT SESI SUARA TERAKHIR', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + ), + ), + const SizedBox(height: 12), + + AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + child: _isLoading + ? const SessionListSkeleton(key: ValueKey('session_skeleton_list')) + : _conversations.isNotEmpty + ? Column( + key: const ValueKey('session_conversation_list'), + children: [ + for (int i = 0; i < _conversations.length; i++) + AnimatedSessionCard( + key: ValueKey('session_card_${_conversations[i]['id'] ?? i}'), + index: i, + child: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _buildSessionCard(context, _conversations[i]), + ), + ), + ], + ) + : Padding( + key: const ValueKey('session_empty_list'), + padding: const EdgeInsets.symmetric(vertical: 36.0, horizontal: 16.0), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.forum_outlined, size: 44, color: AppColors.textLight), + const SizedBox(height: 12), + Text( + 'Belum ada riwayat sesi suara', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + 'Mulai panggilan suara bersama EVA untuk menyimpan sesi percakapanmu.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textLight, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ], + ), + ), + ), + if (showTutorialOverlay) + InteractiveSpotlightOverlay( + key: ValueKey(tutorialState.currentStep), + targetKey: tutorialState.currentStep == TutorialStep.voiceCoins + ? _coinBadgeKey + : _startVoiceButtonKey, + content: TutorialContent.stepContents[tutorialState.currentStep!]!, + onSkip: () => + ref.read(tutorialNotifierProvider.notifier).skipTutorial(), + onNext: () { + if (tutorialState.currentStep == TutorialStep.voiceCoins) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceCoins); + } else if (tutorialState.currentStep == TutorialStep.voiceStart) { + _handleStartVoiceSession(); + } + }, + onTargetTap: () { + if (tutorialState.currentStep == TutorialStep.voiceCoins) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceCoins); + } else if (tutorialState.currentStep == TutorialStep.voiceStart) { + _handleStartVoiceSession(); + } + }, + ), + ], +), + ); + } + + Widget _buildSessionCard(BuildContext context, Map<String, dynamic> conv) { + final title = conv['title']?.toString() ?? 'Sesi Percakapan EVA'; + final time = conv['lastMessageTime']?.toString() ?? 'Hari ini'; + final msgs = conv['messages'] as List? ?? []; + final duration = (conv['duration'] != null && conv['duration'].toString().isNotEmpty) + ? conv['duration'].toString() + : '00:00'; + + String dominantEmoji = conv['dominant_emoji']?.toString() ?? ''; + String dominantEmotion = conv['dominant_emotion']?.toString() ?? ''; + if (dominantEmoji.isEmpty) { + for (final m in msgs) { + if (m is Map && m['sender'] == 'user') { + final emoji = (m['emotionEmoji'] ?? m['emotion_emoji'])?.toString(); + if (emoji != null && emoji.isNotEmpty) { + dominantEmoji = emoji; + break; + } + final text = (m['text'] ?? '').toString(); + if (text.isNotEmpty) { + dominantEmoji = _detectEmotionFallback(text).emoji; + break; + } + } + } + } + if (dominantEmoji.isEmpty) dominantEmoji = '😌'; + if (dominantEmotion.isEmpty) dominantEmotion = 'Tenang & Nyaman'; + + return _buildSessionHistoryCard( + title: title, + duration: duration, + date: time, + dominantEmoji: dominantEmoji, + onTap: () { + Navigator.pushNamed( + context, + '/diary_detail', + arguments: { + 'id': conv['id'], + 'conversationId': conv['id'], + 'title': title, + 'date': time, + 'isSingleSession': true, + 'sessionCount': 1, + 'lastSessionTime': time, + 'moodTag': dominantEmotion, + 'moodEmoji': dominantEmoji, + 'summary': conv['summary'], + 'riskWarning': {'detected': false}, + 'aiInsight': conv['summary'] ?? + 'Percakapan berhasil disintesis dan diproses untuk pemantauan kesehatan emosionalmu.', + 'importantEvents': ['Sesi percakapan aktif selesai tercatat'], + 'emotionalReflection': + 'Emosi terartikulasi secara positif melalui sesi curhat bersama AI.', + 'sessions': [ + { + 'id': conv['id'] ?? 's1', + 'title': title, + 'time': time, + 'moodTag': dominantEmotion, + 'moodEmoji': dominantEmoji, + 'emotionsBreakdown': [ + { + 'name': 'netral', + 'label': 'Netral', + 'emoji': '😐', + 'percent': 0.60, + 'color': const Color(0xFFA7E6FF) + }, + { + 'name': 'happy', + 'label': 'Lega', + 'emoji': '😌', + 'percent': 0.40, + 'color': const Color(0xFFFFE6A7) + }, + ], + 'transcripts': msgs + .map((m) { + final isUser = m['sender'] == 'user'; + final text = (m['text'] ?? '').toString(); + final tag = (m['emotionTag'] ?? m['emotion_tag'])?.toString(); + final emoji = (m['emotionEmoji'] ?? m['emotion_emoji'])?.toString(); + + String resolvedTag = ''; + String resolvedEmoji = ''; + if (isUser) { + if (tag != null && tag.isNotEmpty && tag != 'Refleksi') { + resolvedTag = tag; + resolvedEmoji = (emoji != null && emoji.isNotEmpty) ? emoji : '🌱'; + } else { + final fallback = _detectEmotionFallback(text); + resolvedTag = fallback.tag; + resolvedEmoji = fallback.emoji; + } + } + + return { + 'isUser': isUser, + 'time': m['time'] ?? '', + 'text': text, + 'emotionTag': resolvedTag, + 'emotionEmoji': resolvedEmoji, + }; + }) + .toList(), + } + ], + 'selectedSessionId': conv['id'] ?? 's1', + }, + ); + }, + ); + } + + Widget _buildSessionHistoryCard({ + required String title, + required String duration, + required String date, + required String dominantEmoji, + required VoidCallback onTap, + }) { + return GlassCard( + width: double.infinity, + onTap: onTap, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Text( + dominantEmoji, + style: const TextStyle(fontSize: 22), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + '$date • $duration', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textLight, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right, + color: AppColors.textLight, + size: 20, + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/chat/presentation/widgets/animated_session_card.dart b/apps/eva_mobile/lib/features/chat/presentation/widgets/animated_session_card.dart new file mode 100644 index 0000000..0ce1cca --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/presentation/widgets/animated_session_card.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +class AnimatedSessionCard extends StatefulWidget { + final int index; + final Widget child; + + const AnimatedSessionCard({ + super.key, + required this.index, + required this.child, + }); + + @override + State<AnimatedSessionCard> createState() => _AnimatedSessionCardState(); +} + +class _AnimatedSessionCardState extends State<AnimatedSessionCard> + with SingleTickerProviderStateMixin { + late AnimationController _animController; + late Animation<double> _fadeAnimation; + late Animation<Offset> _slideAnimation; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 320), + ); + + _fadeAnimation = CurvedAnimation( + parent: _animController, + curve: Curves.easeOut, + ); + + _slideAnimation = Tween<Offset>( + begin: const Offset(0.0, 0.10), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animController, + curve: Curves.easeOutCubic, + )); + + final delayMs = (widget.index % 10).clamp(0, 6) * 35; + if (delayMs == 0) { + _animController.forward(); + } else { + Future.delayed(Duration(milliseconds: delayMs), () { + if (mounted) { + _animController.forward(); + } + }); + } + } + + @override + void dispose() { + _animController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: widget.child, + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/chat/presentation/widgets/session_list_skeleton.dart b/apps/eva_mobile/lib/features/chat/presentation/widgets/session_list_skeleton.dart new file mode 100644 index 0000000..acdc8eb --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/presentation/widgets/session_list_skeleton.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +class SessionListSkeleton extends StatefulWidget { + const SessionListSkeleton({super.key}); + + @override + State<SessionListSkeleton> createState() => _SessionListSkeletonState(); +} + +class _SessionListSkeletonState extends State<SessionListSkeleton> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation<double> _animation; + + static const List<double> _titleWidthFactors = [ + 0.65, + 0.50, + 0.72, + 0.58, + 0.62, + 0.54, + 0.68, + 0.48, + 0.70, + 0.56, + ]; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + )..repeat(reverse: true); + _animation = Tween<double>(begin: 0.25, end: 0.65).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final opacity = _animation.value; + return Column( + children: List.generate(10, (index) { + final titleWidth = _titleWidthFactors[index % _titleWidthFactors.length]; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: opacity * 0.18), + borderRadius: BorderRadius.circular(14), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FractionallySizedBox( + widthFactor: titleWidth, + child: Container( + height: 14, + decoration: BoxDecoration( + color: AppColors.textPrimary.withValues(alpha: opacity * 0.16), + borderRadius: BorderRadius.circular(6), + ), + ), + ), + const SizedBox(height: 8), + FractionallySizedBox( + widthFactor: 0.35, + child: Container( + height: 11, + decoration: BoxDecoration( + color: AppColors.textLight.withValues(alpha: opacity * 0.14), + borderRadius: BorderRadius.circular(5), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: AppColors.textLight.withValues(alpha: opacity * 0.14), + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ); + }), + ); + }, + ); + } +} diff --git a/apps/eva_mobile/lib/features/coin/domain/entities/coin_package_entity.dart b/apps/eva_mobile/lib/features/coin/domain/entities/coin_package_entity.dart new file mode 100644 index 0000000..5ef8081 --- /dev/null +++ b/apps/eva_mobile/lib/features/coin/domain/entities/coin_package_entity.dart @@ -0,0 +1,32 @@ +class CoinPackageEntity { + final String id; + final String title; + final int coins; + final int bonusCoins; + final int priceIdr; + final String? badge; + final bool isPopular; + final String description; + + const CoinPackageEntity({ + required this.id, + required this.title, + required this.coins, + this.bonusCoins = 0, + required this.priceIdr, + this.badge, + this.isPopular = false, + this.description = '', + }); + + int get totalCoins => coins + bonusCoins; + + String get formattedPrice { + final str = priceIdr.toString(); + final formatted = str.replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (match) => '${match[1]}.', + ); + return 'Rp $formatted'; + } +} diff --git a/apps/eva_mobile/lib/features/coin/presentation/providers/coin_provider.dart b/apps/eva_mobile/lib/features/coin/presentation/providers/coin_provider.dart new file mode 100644 index 0000000..84a450c --- /dev/null +++ b/apps/eva_mobile/lib/features/coin/presentation/providers/coin_provider.dart @@ -0,0 +1,113 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../domain/entities/coin_package_entity.dart'; + +/// Predefined coin packages available for purchase in Eva Mobile. +final coinPackagesProvider = Provider<List<CoinPackageEntity>>((ref) { + return const [ + CoinPackageEntity( + id: 'pkg_starter', + title: 'Paket Starter', + coins: 50, + bonusCoins: 0, + priceIdr: 15000, + description: 'Cocok untuk mencoba berbicara dengan EVA', + ), + CoinPackageEntity( + id: 'pkg_popular', + title: 'Paket Favorit', + coins: 150, + bonusCoins: 20, + priceIdr: 45000, + badge: 'Paling Populer', + isPopular: true, + description: 'Pilihan hemat untuk bicara rutin & konsultasi psikolog', + ), + CoinPackageEntity( + id: 'pkg_pro', + title: 'Paket Pro Mind', + coins: 300, + bonusCoins: 60, + priceIdr: 89000, + badge: 'Hemat 25%', + description: 'Bicara intensif dengan EVA & konsultasi psikolog ahli', + ), + CoinPackageEntity( + id: 'pkg_ultimate', + title: 'Paket Ultimate', + coins: 600, + bonusCoins: 150, + priceIdr: 169000, + badge: 'Super Hemat', + description: 'Koin terlengkap untuk bicara dengan EVA & konsultasi psikolog', + ), + ]; +}); + +/// Key used to persist user's coin balance locally. +const String _kUserCoinBalanceKey = 'user_coin_balance'; +const int kDefaultCoinBalance = 120; + +/// StateNotifier that manages user's reactive coin balance. +class CoinBalanceNotifier extends StateNotifier<int> { + CoinBalanceNotifier() : super(kDefaultCoinBalance) { + _loadInitialBalance(); + } + + Future<void> _loadInitialBalance() async { + try { + final prefs = await SharedPreferences.getInstance(); + final savedBalance = prefs.getInt(_kUserCoinBalanceKey); + if (savedBalance != null) { + state = savedBalance; + } else { + await prefs.setInt(_kUserCoinBalanceKey, kDefaultCoinBalance); + } + } catch (_) { + // Fallback to default in case of error + } + } + + /// Add purchased or reward coins + Future<void> addCoins(int amount) async { + if (amount <= 0) return; + final newBalance = state + amount; + state = newBalance; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_kUserCoinBalanceKey, newBalance); + } catch (_) {} + } + + /// Spend coins for voice sessions or premium features + Future<bool> spendCoins(int amount) async { + if (amount <= 0 || state < amount) return false; + final newBalance = state - amount; + state = newBalance; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_kUserCoinBalanceKey, newBalance); + } catch (_) {} + return true; + } + + /// Set balance directly (e.g., from server sync) + Future<void> setBalance(int balance) async { + state = balance; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_kUserCoinBalanceKey, balance); + } catch (_) {} + } + + /// Refresh from storage + Future<void> refresh() async { + await _loadInitialBalance(); + } +} + +/// Global provider for user's coin balance +final coinBalanceProvider = StateNotifierProvider<CoinBalanceNotifier, int>((ref) { + return CoinBalanceNotifier(); +}); diff --git a/apps/eva_mobile/lib/features/coin/presentation/screens/coin_purchase_screen.dart b/apps/eva_mobile/lib/features/coin/presentation/screens/coin_purchase_screen.dart new file mode 100644 index 0000000..15e5976 --- /dev/null +++ b/apps/eva_mobile/lib/features/coin/presentation/screens/coin_purchase_screen.dart @@ -0,0 +1,804 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import '../../domain/entities/coin_package_entity.dart'; +import '../providers/coin_provider.dart'; + +/// Screen for browsing, selecting, and purchasing Eva AI coins. +class CoinPurchaseScreen extends ConsumerStatefulWidget { + const CoinPurchaseScreen({super.key}); + + @override + ConsumerState<CoinPurchaseScreen> createState() => _CoinPurchaseScreenState(); +} + +class _CoinPurchaseScreenState extends ConsumerState<CoinPurchaseScreen> { + String _selectedPackageId = 'pkg_popular'; + String _selectedPaymentMethod = 'qris'; + bool _isProcessing = false; + + final List<Map<String, dynamic>> _paymentMethods = [ + { + 'id': 'qris', + 'name': 'QRIS (Instant)', + 'desc': 'GoPay, OVO, DANA, ShopeePay & M-Banking', + 'icon': Icons.qr_code_2_rounded, + }, + { + 'id': 'va', + 'name': 'Virtual Account Bank', + 'desc': 'BCA, Mandiri, BRI, BNI & Permata', + 'icon': Icons.account_balance_rounded, + }, + { + 'id': 'gplay', + 'name': 'Google Play Billing', + 'desc': 'Saldo Google Play & Tagihan Operator', + 'icon': Icons.shop_two_rounded, + }, + ]; + + Future<void> _handlePurchase(CoinPackageEntity package) async { + setState(() => _isProcessing = true); + + // Simulate instant secure payment processing + await Future.delayed(const Duration(milliseconds: 900)); + + if (!mounted) return; + + await ref.read(coinBalanceProvider.notifier).addCoins(package.totalCoins); + + setState(() => _isProcessing = false); + + if (!mounted) return; + + _showSuccessDialog(package); + } + + void _showSuccessDialog(CoinPackageEntity package) { + final currentBalance = ref.read(coinBalanceProvider); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(height: 24), + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFF3F4FE), + border: Border.all( + color: AppColors.primaryContainer, + width: 2, + ), + ), + child: const Center( + child: Icon( + Icons.check_circle_rounded, + size: 46, + color: AppColors.primary, + ), + ), + ), + const SizedBox(height: 18), + Text( + 'Pembelian Berhasil!', + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'Selamat! ${package.totalCoins} Koin telah ditambahkan ke akunmu.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Saldo Koin Sekarang:', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + Row( + children: [ + const Icon( + Icons.monetization_on_rounded, + size: 18, + color: Color(0xFFFFD54F), + ), + const SizedBox(width: 6), + Text( + '$currentBalance Koin', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 26), + CustomButton( + text: 'Gunakan Koin Sekarang', + onPressed: () { + Navigator.pop(ctx); + AppSnackBar.showSuccess( + context, + 'Koin siap digunakan untuk berbicara dengan EVA & konsultasi psikolog!', + ); + }, + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final coinBalance = ref.watch(coinBalanceProvider); + final packages = ref.watch(coinPackagesProvider); + final selectedPackage = packages.firstWhere( + (pkg) => pkg.id == _selectedPackageId, + orElse: () => packages.first, + ); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + // 1. Top Navigation Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Beli Koin EVA', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Top up koin untuk berbicara dengan EVA & konsultasi psikolog', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + + // 2. Scrollable Content + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Balance Hero Card (Theme Purple Gradient) + _buildBalanceHeroCard(coinBalance), + + const SizedBox(height: 24), + + // Section: Pilih Paket Koin + Text( + 'PILIH PAKET KOIN', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 12), + + // Packages Grid + _buildPackagesGrid(packages), + + const SizedBox(height: 24), + + // Section: Metode Pembayaran + Text( + 'METODE PEMBAYARAN', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 12), + + _buildPaymentMethodsList(), + + const SizedBox(height: 24), + + // Section: Manfaat Koin + _buildBenefitsCard(), + + const SizedBox(height: 32), + ], + ), + ), + ), + + // 3. Sticky Bottom Checkout Bar + _buildStickyBottomBar(selectedPackage), + ], + ), + ), + ); + } + + Widget _buildBalanceHeroCard(int coinBalance) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF5358CB), // AppColors.primary + Color(0xFF8B93FF), // AppColors.primaryLight + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5358CB).withValues(alpha: 0.28), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.18), + border: Border.all( + color: Colors.white.withValues(alpha: 0.35), + width: 1.5, + ), + ), + child: const Center( + child: Icon( + Icons.monetization_on_rounded, + size: 30, + color: Color(0xFFFFD54F), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Saldo Koin Kamu', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: 2), + Text( + '$coinBalance Koin', + style: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w900, + color: Colors.white, + letterSpacing: -0.5, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.verified_rounded, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + 'Aktif', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPackagesGrid(List<CoinPackageEntity> packages) { + return Column( + children: packages.map((pkg) { + final isSelected = pkg.id == _selectedPackageId; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: () { + setState(() => _selectedPackageId = pkg.id); + }, + borderRadius: BorderRadius.circular(18), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFFF3F4FE) : Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFE2E8F0), + width: isSelected ? 2 : 1, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.12), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ] + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? AppColors.primary.withValues(alpha: 0.12) + : const Color(0xFFF8F9FE), + ), + child: Center( + child: Icon( + Icons.toll_rounded, + size: 24, + color: isSelected ? AppColors.primary : const Color(0xFFFFB300), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 3, + children: [ + Text( + pkg.title, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + if (pkg.badge != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 2, + ), + decoration: BoxDecoration( + color: pkg.isPopular + ? const Color(0xFFFFF7ED) + : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: pkg.isPopular + ? const Color(0xFFFDBA74) + : const Color(0xFFC7D2FE), + width: 0.8, + ), + ), + child: Text( + pkg.badge!, + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w800, + color: pkg.isPopular + ? const Color(0xFFC2410C) + : AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 2, + children: [ + Text( + '${pkg.totalCoins} Koin', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: isSelected ? AppColors.primary : AppColors.textPrimary, + ), + ), + if (pkg.bonusCoins > 0) + Text( + '(+${pkg.bonusCoins} Bonus)', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF10B981), + ), + ), + ], + ), + if (pkg.description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + pkg.description, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + pkg.formattedPrice, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Icon( + isSelected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_off_rounded, + size: 20, + color: isSelected ? AppColors.primary : const Color(0xFFCBD5E1), + ), + ], + ), + ], + ), + ), + ), + ); + }).toList(), + ); + } + + Widget _buildPaymentMethodsList() { + return Column( + children: _paymentMethods.map((method) { + final isSelected = method['id'] == _selectedPaymentMethod; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: InkWell( + onTap: () { + setState(() => _selectedPaymentMethod = method['id'] as String); + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFE2E8F0), + width: isSelected ? 1.5 : 1, + ), + ), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: isSelected + ? AppColors.primaryContainer + : const Color(0xFFF8F9FE), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Icon( + method['icon'] as IconData, + size: 20, + color: isSelected ? AppColors.primary : const Color(0xFF64748B), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + method['name'] as String, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + method['desc'] as String, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Icon( + isSelected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_off_rounded, + size: 18, + color: isSelected ? AppColors.primary : const Color(0xFFCBD5E1), + ), + ], + ), + ), + ), + ); + }).toList(), + ); + } + + Widget _buildBenefitsCard() { + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.stars_rounded, + size: 20, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Text( + 'Manfaat Koin EVA', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 14), + _buildBenefitItem( + icon: Icons.record_voice_over_rounded, + title: 'Bisa Berbicara Langsung dengan EVA', + desc: + 'Dapat digunakan untuk sesi panggilan suara (Voice Call) dan interaksi curhat real-time dua arah bersama AI EVA.', + ), + const SizedBox(height: 12), + _buildBenefitItem( + icon: Icons.psychology_rounded, + title: 'Alat Transaksi Konsultasi Psikolog', + desc: + 'Dapat digunakan sebagai alat transaksi untuk menjadwalkan dan berkonsultasi langsung dengan psikolog pada halaman Layanan.', + ), + ], + ), + ); + } + + Widget _buildBenefitItem({ + required IconData icon, + required String title, + required String desc, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 14, color: AppColors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + desc, + style: GoogleFonts.inter( + fontSize: 11.5, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildStickyBottomBar(CoinPackageEntity selectedPackage) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + border: const Border( + top: BorderSide(color: Color(0xFFE2E8F0), width: 1), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, -3), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Total Pembayaran', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + selectedPackage.formattedPrice, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: CustomButton( + text: 'Beli Sekarang', + isLoading: _isProcessing, + onPressed: _isProcessing ? null : () => _handlePurchase(selectedPackage), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/data/repositories/fallback_dass_repository.dart b/apps/eva_mobile/lib/features/dass/data/repositories/fallback_dass_repository.dart new file mode 100644 index 0000000..9a3330b --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/data/repositories/fallback_dass_repository.dart @@ -0,0 +1,41 @@ +import '../../domain/repositories/dass_repository.dart'; +import '../../domain/entities/dass_assessment_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackDASSRepository implements DASSRepository { + final DASSRepository _remote; + final DASSRepository _mock; + + FallbackDASSRepository({ + required DASSRepository remote, + required DASSRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future<DASSAssessmentEntity> getTodayAssessment() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getTodayAssessment(), + mockCall: () => _mock.getTodayAssessment(), + operationName: 'getTodayAssessment', + ); + } + + @override + Future<DASSAssessmentEntity> updateAssessment(List<Map<String, dynamic>> items) { + return FallbackExecutor.execute( + remoteCall: () => _remote.updateAssessment(items), + mockCall: () => _mock.updateAssessment(items), + operationName: 'updateAssessment', + ); + } + + @override + Future<DASSAssessmentEntity> extractTodayAssessment() { + return FallbackExecutor.execute( + remoteCall: () => _remote.extractTodayAssessment(), + mockCall: () => _mock.extractTodayAssessment(), + operationName: 'extractTodayAssessment', + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/providers/dass_provider.dart b/apps/eva_mobile/lib/features/dass/presentation/providers/dass_provider.dart index 379048d..119cd3e 100644 --- a/apps/eva_mobile/lib/features/dass/presentation/providers/dass_provider.dart +++ b/apps/eva_mobile/lib/features/dass/presentation/providers/dass_provider.dart @@ -1,16 +1,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../data/datasources/dass_datasource.dart'; import '../../data/repositories/dass_repository_impl.dart'; +import '../../data/repositories/fallback_dass_repository.dart'; import '../../domain/entities/dass_assessment_entity.dart'; import '../../domain/repositories/dass_repository.dart'; import '../../../monitoring/presentation/providers/monitoring_provider.dart'; final dassRepositoryProvider = Provider<DASSRepository>((ref) { + final mockRepo = DASSRepositoryImpl(MockDASSDataSource()); + final remoteRepo = DASSRepositoryImpl(RemoteDASSDataSource()); + if (AppConfig.useMockData) { - return DASSRepositoryImpl(MockDASSDataSource()); + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackDASSRepository(remote: remoteRepo, mock: mockRepo); } else { - return DASSRepositoryImpl(RemoteDASSDataSource()); + return remoteRepo; } }); diff --git a/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart b/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart new file mode 100644 index 0000000..a599767 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart @@ -0,0 +1,666 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../../../widgets/app_snack_bar.dart'; +import '../../../../widgets/staggered_entrance.dart'; +import '../providers/dass_provider.dart'; +import '../widgets/dass_floating_save_bar.dart'; +import '../widgets/dass_question_card.dart'; +import '../widgets/dass_skeleton_loader.dart'; +import '../widgets/dass_subscale_info_sheet.dart'; +import '../widgets/dass_subscales_card.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/presentation/providers/tutorial_provider.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; + +/// Screen for displaying, reviewing, and verifying DASS-21 psychological self-assessments +/// with period filtering, explainable AI accordion insights, and 3-subscale evaluations. +class DASSAssessmentScreen extends ConsumerStatefulWidget { + final String initialPeriod; + + const DASSAssessmentScreen({ + super.key, + this.initialPeriod = 'today', + }); + + @override + ConsumerState<DASSAssessmentScreen> createState() => _DASSAssessmentScreenState(); +} + +class _DASSAssessmentScreenState extends ConsumerState<DASSAssessmentScreen> { + final GlobalKey _subscalesCardKey = GlobalKey(debugLabel: 'dassSubscalesCard'); + final GlobalKey _firstDassItemKey = GlobalKey(debugLabel: 'firstDassItemCard'); + final ScrollController _dassScrollController = ScrollController(); + late String _currentPeriod; + String _selectedFilter = 'all'; // 'all', 'stress', 'anxiety', 'depression' + final Set<int> _expandedItemIds = {}; + + static const String _whatsappEmergencyUrl = + 'https://api.whatsapp.com/send/?phone=6281380073120&text=halo%20kak%2C%20saya%20ingin%20bercerita%20mengenai...&type=phone_number&app_absent=0'; + + @override + void initState() { + super.initState(); + _currentPeriod = widget.initialPeriod; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final tutorialState = ref.read(tutorialNotifierProvider); + if (tutorialState.isActive && + tutorialState.currentStep == TutorialStep.dassItem) { + _scrollToFirstDassItem(); + } + }); + } + + @override + void dispose() { + _dassScrollController.dispose(); + super.dispose(); + } + + void _scrollToFirstDassItem() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_firstDassItemKey.currentContext != null) { + Scrollable.ensureVisible( + _firstDassItemKey.currentContext!, + alignment: 0.05, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + ); + } else { + Future.delayed(const Duration(milliseconds: 120), () { + if (!mounted) return; + if (_firstDassItemKey.currentContext != null) { + Scrollable.ensureVisible( + _firstDassItemKey.currentContext!, + alignment: 0.05, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + ); + } + }); + } + }); + } + + Future<void> _openWhatsappEmergency() async { + final uri = Uri.parse(_whatsappEmergencyUrl); + try { + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched) { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } + } catch (_) { + try { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } catch (_) { + if (mounted) { + AppSnackBar.showError( + context, + 'Tidak dapat membuka WhatsApp Konseling Krisis', + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + ref.listen<TutorialState>(tutorialNotifierProvider, (previous, next) { + if (next.isActive && next.currentStep == TutorialStep.dassItem) { + _scrollToFirstDassItem(); + } + }); + + final dassState = ref.watch(dassAssessmentNotifierProvider); + final notifier = ref.read(dassAssessmentNotifierProvider.notifier); + final isEditable = _currentPeriod == 'today'; + + final currentAssessment = dassState.assessment.asData?.value; + final staged = dassState.stagedScores; + final bool hasChanges = isEditable && + currentAssessment != null && + currentAssessment.items.any((it) => (staged[it.itemId] ?? it.score) != it.score); + final int changedCount = currentAssessment != null + ? currentAssessment.items + .where((it) => (staged[it.itemId] ?? it.score) != it.score) + .length + : 0; + final bool isUnverified = currentAssessment != null && !currentAssessment.verifiedByUser; + final bool showSaveBar = + isEditable && currentAssessment != null && (hasChanges || isUnverified); + + final tutorialState = ref.watch(tutorialNotifierProvider); + final bool isDassDetail = tutorialState.isActive && + tutorialState.currentStep == TutorialStep.dassDetail; + final bool isDassItem = tutorialState.isActive && + tutorialState.currentStep == TutorialStep.dassItem; + final bool showTutorial = isDassDetail || isDassItem; + final GlobalKey? activeTargetKey = isDassDetail + ? _subscalesCardKey + : (isDassItem ? _firstDassItemKey : null); + + return Scaffold( + body: Stack( + children: [ + Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Column( + children: [ + StaggeredEntrance( + index: 0, + child: _buildTopBar(context, notifier), + ), + StaggeredEntrance( + index: 1, + child: _buildPeriodSelector(), + ), + Expanded( + child: dassState.assessment.when( + loading: () => DassSkeletonLoader( + subscalesCardKey: _subscalesCardKey, + firstItemKey: _firstDassItemKey, + ), + error: (err, _) => RefreshIndicator( + onRefresh: () => notifier.loadTodayAssessment(), + color: AppColors.primary, + backgroundColor: Colors.white, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox(height: MediaQuery.of(context).size.height * 0.2), + Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline_rounded, + color: Colors.redAccent, size: 44), + const SizedBox(height: 12), + Text( + 'Gagal memuat asesmen DASS-21', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w700, + color: const Color(0xFF1E1B4B), + ), + ), + const SizedBox(height: 6), + Text( + '$err', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, color: Colors.redAccent), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => notifier.loadTodayAssessment(), + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Coba Lagi'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + ), + ], + ), + ), + ], + ), + ), + data: (assessment) { + final filteredItems = assessment.items.where((it) { + if (_selectedFilter == 'all') return true; + return it.scale.toLowerCase() == _selectedFilter.toLowerCase(); + }).toList(); + + return RefreshIndicator( + onRefresh: () => notifier.loadTodayAssessment(), + color: AppColors.primary, + backgroundColor: Colors.white, + displacement: 24, + child: ListView( + controller: _dassScrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + children: [ + StaggeredEntrance( + index: 2, + child: _buildModeBanner(isEditable), + ), + const SizedBox(height: 16), + StaggeredEntrance( + index: 3, + child: DassSubscalesCard( + key: _subscalesCardKey, + assessment: assessment, + stagedScores: staged, + onInfoTap: () => DassSubscaleInfoSheet.showGuideDialog(context), + onSubscaleTap: (label, score, severityIndo, scaleType) { + DassSubscaleInfoSheet.show( + context: context, + label: label, + score: score, + severityIndo: severityIndo, + scaleType: scaleType, + scoreColor: DassSubscalesCard.getScoreColor(severityIndo), + ); + }, + onExtremelySevereTap: (label, score, scaleType) { + DassSubscaleInfoSheet.showPsychologistReferral( + context: context, + label: label, + score: score, + scaleType: scaleType, + onOpenWhatsapp: _openWhatsappEmergency, + ); + }, + ), + ), + const SizedBox(height: 16), + StaggeredEntrance( + index: 4, + child: _buildFilterChips(assessment.items.length), + ), + const SizedBox(height: 16), + StaggeredEntrance( + index: 5, + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: filteredItems.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final item = filteredItems[index]; + final currentScore = staged[item.itemId] ?? item.score; + return DassQuestionCard( + key: index == 0 ? _firstDassItemKey : null, + item: item, + currentScore: currentScore, + isEditable: isEditable, + isExpanded: _expandedItemIds.contains(item.itemId), + onToggleExpand: () { + setState(() { + if (_expandedItemIds.contains(item.itemId)) { + _expandedItemIds.remove(item.itemId); + } else { + _expandedItemIds.add(item.itemId); + } + }); + }, + onScoreChanged: (newScore) => + notifier.updateScore(item.itemId, newScore), + onUndoAI: () => notifier.updateScore(item.itemId, item.score), + ); + }, + ), + ), + const SizedBox(height: 320), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ), + DassFloatingSaveBar( + showSaveBar: showSaveBar, + hasChanges: hasChanges, + isUnverified: isUnverified, + changedCount: changedCount, + isSaving: dassState.isSaving, + onSave: () async { + final scaffoldMessenger = ScaffoldMessenger.of(context); + final success = await notifier.saveAssessment(); + if (!mounted) return; + if (success) { + AppSnackBar.showWithMessenger( + scaffoldMessenger, + message: 'Asesmen DASS-21 hari ini berhasil diverifikasi dan disimpan!', + variant: SnackBarVariant.success, + ); + } + }, + ), + if (showTutorial && activeTargetKey != null) + InteractiveSpotlightOverlay( + key: ValueKey(tutorialState.currentStep), + targetKey: activeTargetKey, + content: TutorialContent.stepContents[tutorialState.currentStep!]!, + onSkip: () { + ref.read(tutorialNotifierProvider.notifier).skipTutorial(); + Navigator.pop(context); + }, + onNext: () { + final current = tutorialState.currentStep; + if (current == TutorialStep.dassDetail) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.dassDetail); + _scrollToFirstDassItem(); + } else if (current == TutorialStep.dassItem) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.dassItem); + Navigator.pop(context); + } + }, + onTargetTap: () { + final current = tutorialState.currentStep; + if (current == TutorialStep.dassDetail) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.dassDetail); + _scrollToFirstDassItem(); + } else if (current == TutorialStep.dassItem) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.dassItem); + Navigator.pop(context); + } + }, + ), + ], + ), + ); + } + + Widget _buildTopBar(BuildContext context, DASSAssessmentNotifier notifier) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Lembar Evaluasi DASS-21', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: const Color(0xFF1E1B4B), + ), + ), + Text( + 'Depression Anxiety Stress Scale (21 Butir)', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: const Color(0xFF6B7280), + ), + ), + ], + ), + ), + IconButton( + onPressed: () async { + final confirm = await showDialog<bool>( + context: context, + builder: (dialogCtx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + const Icon(Icons.auto_awesome, color: Color(0xFF7C3AED), size: 24), + const SizedBox(width: 8), + Text( + 'Konfirmasi Ekstraksi AI', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w700, + color: const Color(0xFF1E1B4B), + ), + ), + ], + ), + content: Text( + 'Apakah Anda yakin ingin memicu analisis AI untuk mengekstrak 21 butir DASS-21 dari riwayat percakapan hari ini?\n\n' + 'Proses ini akan menganalisis transkrip percakapan terbaru untuk mendeteksi indikator emosi Anda.', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + color: const Color(0xFF4B5563), + height: 1.45, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogCtx, false), + child: Text( + 'Batal', + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w600, + color: const Color(0xFF6B7280), + ), + ), + ), + ElevatedButton.icon( + onPressed: () => Navigator.pop(dialogCtx, true), + icon: const Icon(Icons.auto_awesome, size: 16), + label: Text( + 'Ekstrak Sekarang', + style: GoogleFonts.plusJakartaSans(fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF7C3AED), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ], + ), + ); + + if (confirm != true || !mounted) return; + + AppSnackBar.showPrimary( + this.context, + 'Menganalisis dan mengekstrak DASS-21 dari riwayat dialog hari ini...', + duration: const Duration(seconds: 4), + leading: const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + ); + await notifier.extractTodayAssessment(); + }, + icon: const Icon(Icons.auto_awesome, size: 20, color: Color(0xFF7C3AED)), + tooltip: 'Ekstrak AI dari Percakapan Hari Ini', + ), + ], + ), + ); + } + + Widget _buildPeriodSelector() { + final periods = [ + {'key': 'today', 'label': 'Hari Ini'}, + {'key': 'week', 'label': 'Minggu Ini'}, + {'key': 'month', 'label': 'Bulan Ini'}, + ]; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFE5E7EB).withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: periods.map((p) { + final isSelected = p['key'] == _currentPeriod; + return Expanded( + child: GestureDetector( + onTap: () { + setState(() { + _currentPeriod = p['key']!; + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isSelected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Center( + child: Text( + p['label']!, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? const Color(0xFF1E1B4B) : const Color(0xFF6B7280), + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildModeBanner(bool isEditable) { + if (isEditable) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFBFDBFE)), + ), + child: Row( + children: [ + const Icon(Icons.edit_note_rounded, color: Color(0xFF2563EB), size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Mode Koreksi Mandiri: Anda dapat menyesuaikan skor DASS-21 hasil sintesis AI hari ini.', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF1E40AF), + height: 1.35, + ), + ), + ), + ], + ), + ); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + const Icon(Icons.history_rounded, color: Color(0xFF64748B), size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Arsip Historis: Data asesmen hari sebelumnya tersimpan secara permanen untuk evaluasi klinis.', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF475569), + height: 1.35, + ), + ), + ), + ], + ), + ); + } + + Widget _buildFilterChips(int totalCount) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildChip('all', 'Semua ($totalCount)'), + const SizedBox(width: 8), + _buildChip('stress', 'Stres (7)'), + const SizedBox(width: 8), + _buildChip('anxiety', 'Kecemasan (7)'), + const SizedBox(width: 8), + _buildChip('depression', 'Depresi (7)'), + ], + ), + ); + } + + Widget _buildChip(String filterKey, String label) { + final isSelected = _selectedFilter == filterKey; + return GestureDetector( + onTap: () { + setState(() { + _selectedFilter = filterKey; + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF1E1B4B) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? const Color(0xFF1E1B4B) : const Color(0xFFE2E8F0), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: Text( + label, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? Colors.white : const Color(0xFF64748B), + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/bouncing_button.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/bouncing_button.dart new file mode 100644 index 0000000..2e63407 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/bouncing_button.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +class BouncingButton extends StatefulWidget { + final Widget child; + final VoidCallback? onTap; + final double scaleFactor; + + const BouncingButton({ + super.key, + required this.child, + this.onTap, + this.scaleFactor = 0.94, + }); + + @override + State<BouncingButton> createState() => _BouncingButtonState(); +} + +class _BouncingButtonState extends State<BouncingButton> with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation<double> _scale; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 90), + reverseDuration: const Duration(milliseconds: 110), + ); + _scale = Tween<double>(begin: 1.0, end: widget.scaleFactor).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onTapDown(TapDownDetails details) { + if (widget.onTap != null) { + _controller.forward(); + } + } + + void _onTapUp(TapUpDetails details) { + if (widget.onTap != null) { + _controller.reverse(); + widget.onTap!(); + } + } + + void _onTapCancel() { + if (widget.onTap != null) { + _controller.reverse(); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: _onTapDown, + onTapUp: _onTapUp, + onTapCancel: _onTapCancel, + child: ScaleTransition( + scale: _scale, + child: widget.child, + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_ai_analysis_accordion.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_ai_analysis_accordion.dart new file mode 100644 index 0000000..43773d1 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_ai_analysis_accordion.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../domain/entities/dass_assessment_entity.dart'; + +class DassAIAnalysisAccordion extends StatefulWidget { + final bool isExpanded; + final DASSItemEntity item; + final Color confBg; + final Color confColor; + final int confPercent; + final String confLabel; + final bool hasEvidence; + final bool hasReason; + + const DassAIAnalysisAccordion({ + super.key, + required this.isExpanded, + required this.item, + required this.confBg, + required this.confColor, + required this.confPercent, + required this.confLabel, + required this.hasEvidence, + required this.hasReason, + }); + + @override + State<DassAIAnalysisAccordion> createState() => _DassAIAnalysisAccordionState(); +} + +class _DassAIAnalysisAccordionState extends State<DassAIAnalysisAccordion> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation<double> _expandAnimation; + late final Animation<Offset> _slideAnimation; + late final Animation<double> _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 320), + vsync: this, + ); + _expandAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + _slideAnimation = Tween<Offset>( + begin: const Offset(0.0, -0.08), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + )); + _fadeAnimation = CurvedAnimation( + parent: _controller, + curve: const Interval(0.1, 1.0, curve: Curves.easeOut), + reverseCurve: const Interval(0.0, 0.7, curve: Curves.easeIn), + ); + + if (widget.isExpanded) { + _controller.value = 1.0; + } + } + + @override + void didUpdateWidget(covariant DassAIAnalysisAccordion oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded != oldWidget.isExpanded) { + if (widget.isExpanded) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + if (_controller.isDismissed && !widget.isExpanded) { + return const SizedBox.shrink(); + } + return ClipRect( + child: SizeTransition( + sizeFactor: _expandAnimation, + alignment: Alignment.topCenter, + child: FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: child, + ), + ), + ), + ); + }, + child: Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Confidence Meter Bar + Row( + children: [ + Text( + 'Tingkat Keyakinan AI', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: const Color(0xFF334155), + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: widget.confBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: widget.confColor.withValues(alpha: 0.3)), + ), + child: Text( + '${widget.confPercent}% • ${widget.confLabel}', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: widget.confColor, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Container( + height: 5, + width: double.infinity, + color: const Color(0xFFE2E8F0), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: widget.item.confidence.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: widget.confColor, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + + // 2. AI Evidence Quote (if present) + if (widget.hasEvidence) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.format_quote_rounded, size: 16, color: Color(0xFF6366F1)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Bukti Obrolan Pengguna:', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF64748B), + ), + ), + const SizedBox(height: 2), + Text( + '"${widget.item.evidence}"', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontStyle: FontStyle.italic, + color: const Color(0xFF1E293B), + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ], + + // 3. AI Rationale / Reason (if present) + if (widget.hasReason) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFDDD6FE)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.psychology_alt_rounded, size: 16, color: Color(0xFF7C3AED)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Penalaran AI Eva:', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF6D28D9), + ), + ), + const SizedBox(height: 2), + Text( + widget.item.reason!, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: const Color(0xFF334155), + height: 1.35, + ), + ), + ], + ), + ), + ], + ), + ), + ] else if (!widget.hasEvidence) ...[ + const SizedBox(height: 8), + Text( + 'Tidak ditemukan indikasi keluhan pada transkrip obrolan hari ini (Skor 0).', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontStyle: FontStyle.italic, + color: const Color(0xFF64748B), + ), + ), + ], + + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.info_outline_rounded, size: 12, color: Color(0xFF94A3B8)), + const SizedBox(width: 4), + Expanded( + child: Text( + 'Pilihan ini dianalisis otomatis. Kamu dapat mengoreksi skor kapan saja di bawah.', + style: GoogleFonts.plusJakartaSans( + fontSize: 9.5, + color: const Color(0xFF94A3B8), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_floating_save_bar.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_floating_save_bar.dart new file mode 100644 index 0000000..027b7d8 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_floating_save_bar.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; + +/// Floating bottom action bar displaying verification/save status and assessment save button. +class DassFloatingSaveBar extends StatelessWidget { + final bool showSaveBar; + final bool hasChanges; + final bool isUnverified; + final int changedCount; + final bool isSaving; + final VoidCallback onSave; + + const DassFloatingSaveBar({ + super.key, + required this.showSaveBar, + required this.hasChanges, + required this.isUnverified, + required this.changedCount, + required this.isSaving, + required this.onSave, + }); + + @override + Widget build(BuildContext context) { + final String titleText = hasChanges + ? '$changedCount Butir Diubah' + : (isUnverified ? 'Sintesis AI Siap' : 'Asesmen Tersimpan'); + final String subtitleText = hasChanges + ? 'Simpan koreksi asesmen' + : (isUnverified ? 'Simpan asesmen hari ini' : 'Skor telah terverifikasi'); + const String buttonText = 'Simpan'; + final IconData barIcon = + hasChanges ? Icons.edit_note_rounded : Icons.check_circle_outline_rounded; + + return Positioned( + left: 16, + right: 16, + bottom: 16, + child: IgnorePointer( + ignoring: !showSaveBar, + child: AnimatedSlide( + offset: showSaveBar ? Offset.zero : const Offset(0, 2.0), + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: showSaveBar ? 1.0 : 0.0, + duration: const Duration(milliseconds: 250), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: const Color(0xFF1E1B4B), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1E1B4B).withValues(alpha: 0.35), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + barIcon, + color: Colors.white, + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + titleText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 2), + Text( + subtitleText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: Colors.white.withValues(alpha: 0.75), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: isSaving ? null : onSave, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : Text( + buttonText, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_form_card.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_form_card.dart index 6360d0a..9a8a288 100644 --- a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_form_card.dart +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_form_card.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_snack_bar.dart'; import '../../../../widgets/glass_card.dart'; import '../../../../widgets/skeleton_shimmer.dart'; import '../providers/dass_provider.dart'; @@ -249,14 +250,11 @@ class _DASSFormCardState extends ConsumerState<DASSFormCard> { final success = await notifier.saveAssessment(); if (!mounted) return; if (success) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( + AppSnackBar.showWithMessenger( + scaffoldMessenger, + message: 'Koreksi DASS-21 berhasil disimpan & ritem emosional diselaraskan!', - style: GoogleFonts.plusJakartaSans(), - ), - backgroundColor: const Color(0xFF10B981), - ), + variant: SnackBarVariant.success, ); } }, diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_question_card.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_question_card.dart new file mode 100644 index 0000000..4675f0e --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_question_card.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; +import '../../domain/entities/dass_assessment_entity.dart'; +import 'bouncing_button.dart'; +import 'dass_ai_analysis_accordion.dart'; + +/// Card displaying an individual DASS-21 question with 4-point Likert buttons and explainable AI insights. +class DassQuestionCard extends StatelessWidget { + final DASSItemEntity item; + final int currentScore; + final bool isEditable; + final bool isExpanded; + final VoidCallback onToggleExpand; + final ValueChanged<int> onScoreChanged; + final VoidCallback onUndoAI; + + const DassQuestionCard({ + super.key, + required this.item, + required this.currentScore, + required this.isEditable, + required this.isExpanded, + required this.onToggleExpand, + required this.onScoreChanged, + required this.onUndoAI, + }); + + static Color getScaleColor(String scale) { + switch (scale) { + case 'stress': + return const Color(0xFFF57C00); + case 'anxiety': + return const Color(0xFF7C3AED); + case 'depression': + default: + return const Color(0xFF2563EB); + } + } + + static String getScaleLabel(String scale) { + switch (scale) { + case 'stress': + return 'Stres'; + case 'anxiety': + return 'Kecemasan'; + case 'depression': + default: + return 'Depresi'; + } + } + + static Color getConfidenceColor(double conf) { + if (conf >= 0.8) return const Color(0xFF16A34A); + if (conf >= 0.5) return const Color(0xFFD97706); + return const Color(0xFFDC2626); + } + + static Color getConfidenceBg(double conf) { + if (conf >= 0.8) return const Color(0xFFF0FDF4); + if (conf >= 0.5) return const Color(0xFFFFFBEB); + return const Color(0xFFFEF2F2); + } + + static String getConfidenceLabel(double conf) { + if (conf >= 0.8) return 'Tinggi'; + if (conf >= 0.5) return 'Sedang'; + return 'Rendah'; + } + + @override + Widget build(BuildContext context) { + final scaleColor = getScaleColor(item.scale); + final scaleLabel = getScaleLabel(item.scale); + final isAnsweredByAI = item.confidence > 0 || + (item.evidence != null && item.evidence!.trim().isNotEmpty) || + (item.reason != null && item.reason!.trim().isNotEmpty); + + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Item Number & Subscale Tag + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 6, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: scaleColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Item ${item.itemId} • $scaleLabel', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: scaleColor, + ), + ), + ), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 4, + children: [ + if (item.isUserEdited || (isEditable && item.score != currentScore)) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Telah Dikoreksi', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w600, + color: const Color(0xFF4F46E5), + ), + ), + ), + if (isEditable && item.score != currentScore && isAnsweredByAI) + GestureDetector( + onTap: onUndoAI, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.undo_rounded, size: 10, color: Color(0xFF4B5563)), + const SizedBox(width: 3), + Text( + 'Saran AI (${item.score})', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w600, + color: const Color(0xFF4B5563), + ), + ), + ], + ), + ), + ), + if (isAnsweredByAI) _buildAIAnalysisIconButton(), + ], + ), + ], + ), + const SizedBox(height: 8), + + // Question Text + Text( + item.questionText, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w600, + color: const Color(0xFF1F2937), + height: 1.35, + ), + ), + + // Explainable AI Accordion (Evidence, Confidence, Reason) + if (isAnsweredByAI) _buildAIAnalysisSection(), + + const SizedBox(height: 12), + + // Likert Selector Options (0 - 3) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + _buildLikertButton(0, '0: Tdk Ada', currentScore == 0), + const SizedBox(width: 6), + _buildLikertButton(1, '1: Kadang', currentScore == 1), + const SizedBox(width: 6), + _buildLikertButton(2, '2: Sering', currentScore == 2), + const SizedBox(width: 6), + _buildLikertButton(3, '3: Selalu', currentScore == 3), + ], + ), + ), + ], + ), + ); + } + + Widget _buildAIAnalysisIconButton() { + final conf = item.confidence; + final confColor = getConfidenceColor(conf); + final confBg = getConfidenceBg(conf); + final confPercent = (conf * 100).round(); + + return Tooltip( + message: isExpanded + ? 'Tutup Analisis AI' + : (conf > 0 ? 'Analisis Eva ($confPercent% Yakin)' : 'Lihat Analisis Eva'), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onToggleExpand, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: isExpanded ? confBg : const Color(0xFFF1F5F9), + shape: BoxShape.circle, + border: Border.all( + color: isExpanded ? confColor.withValues(alpha: 0.4) : const Color(0xFFE2E8F0), + ), + ), + child: Icon( + Icons.auto_awesome_rounded, + size: 15, + color: isExpanded ? confColor : const Color(0xFF6366F1), + ), + ), + ), + ), + ); + } + + Widget _buildAIAnalysisSection() { + final hasEvidence = item.evidence != null && item.evidence!.trim().isNotEmpty; + final hasReason = item.reason != null && item.reason!.trim().isNotEmpty; + final conf = item.confidence; + final confPercent = (conf * 100).round(); + final confColor = getConfidenceColor(conf); + final confBg = getConfidenceBg(conf); + final confLabel = getConfidenceLabel(conf); + + return DassAIAnalysisAccordion( + isExpanded: isExpanded, + item: item, + confBg: confBg, + confColor: confColor, + confPercent: confPercent, + confLabel: confLabel, + hasEvidence: hasEvidence, + hasReason: hasReason, + ); + } + + Widget _buildLikertButton(int score, String label, bool isSelected) { + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: BouncingButton( + onTap: isEditable ? () => onScoreChanged(score) : null, + scaleFactor: 0.92, + child: AnimatedScale( + scale: isSelected ? 1.04 : 1.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + padding: const EdgeInsets.symmetric(vertical: 9), + decoration: BoxDecoration( + color: isSelected + ? AppColors.primary + : (isEditable ? Colors.white : const Color(0xFFF3F4F6)), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppColors.primary + : (isEditable ? const Color(0xFFD1D5DB) : const Color(0xFFE5E7EB)), + width: isSelected ? 1.8 : 1.0, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.28), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: isSelected ? FontWeight.w800 : FontWeight.w500, + color: isSelected + ? Colors.white + : (isEditable ? const Color(0xFF4B5563) : const Color(0xFF9CA3AF)), + ), + child: Text(label), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_skeleton_loader.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_skeleton_loader.dart new file mode 100644 index 0000000..951b580 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_skeleton_loader.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +import '../../../../widgets/glass_card.dart'; +import '../../../../widgets/skeleton_shimmer.dart'; + +/// Shimmer skeleton loader for the DASS-21 Assessment Screen. +class DassSkeletonLoader extends StatelessWidget { + final GlobalKey? subscalesCardKey; + final GlobalKey? firstItemKey; + const DassSkeletonLoader({ + super.key, + this.subscalesCardKey, + this.firstItemKey, + }); + + @override + Widget build(BuildContext context) { + return SkeletonShimmerHost( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Three Score Summary Cards (Depresi, Kecemasan, Stres) + Container( + key: subscalesCardKey, + child: Row( + children: List.generate( + 3, + (index) => Expanded( + child: Container( + margin: EdgeInsets.only( + left: index == 0 ? 0 : 4, + right: index == 2 ? 0 : 4, + ), + child: GlassCard( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), + child: Column( + children: const [ + SkeletonCircle(size: 32), + SizedBox(height: 8), + SkeletonLine(width: 50, height: 12), + SizedBox(height: 6), + SkeletonLine(width: 40, height: 16), + ], + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 18), + + // 2. Main Clinical Insight Card Skeleton + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SkeletonCircle(size: 40), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 140, height: 16), + SizedBox(height: 6), + SkeletonLine(width: 90, height: 12), + ], + ), + ], + ), + const SizedBox(height: 16), + const SkeletonLine(width: double.infinity, height: 13), + const SizedBox(height: 8), + const SkeletonLine(width: 240, height: 13), + const SizedBox(height: 16), + const SkeletonBox(width: double.infinity, height: 40, borderRadius: 10), + ], + ), + ), + const SizedBox(height: 18), + + // 3. Question Item Preview Skeletons + ...List.generate( + 2, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: GlassCard( + key: index == 0 ? firstItemKey : null, + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const SkeletonBox(width: 28, height: 28, borderRadius: 6), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 160, height: 14), + SizedBox(height: 6), + SkeletonLine(width: 110, height: 11), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscale_info_sheet.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscale_info_sheet.dart new file mode 100644 index 0000000..33d0421 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscale_info_sheet.dart @@ -0,0 +1,430 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; + +/// Modal bottom sheets presenting DASS-21 subscale indicator explanations and clinical psychologist referral. +class DassSubscaleInfoSheet { + static void show({ + required BuildContext context, + required String label, + required int score, + required String severityIndo, + required String scaleType, + required Color scoreColor, + }) { + String definition = ''; + String tip = ''; + + if (scaleType == 'depression') { + definition = + 'Subskala Depresi mengukur tingkat suasana hati disforik, hilangnya motivasi/inisiatif, anhedonia (kesulitan merasakan hal positif), dan perasaan ketidakberhargaan.'; + tip = + 'Coba lakukan aktivitas kecil yang kamu sukai, luapkan pikiran melalui percakapan bersama EVA, atau jalan santai menghirup udara segar.'; + } else if (scaleType == 'anxiety') { + definition = + 'Subskala Kecemasan mengukur stimulasi otonomik (otot tegang, detak jantung kencang), cemas situasional, dan ketakutan akan kehilangan kendali.'; + tip = + 'Lakukan teknik pernapasan 4-7-8 atau relaksasi otot bertahap untuk menurunkan stimulasi fisik berlebih.'; + } else { + definition = + 'Subskala Stres mengukur ketegangan syaraf non-spesifik, reaksi berlebihan pada masalah harian, kegelisahan, dan ketidakmampuan untuk bersantai.'; + tip = + 'Ambil jeda istirahat dari pekerjaan/tugas, hindari multitasking berlebih, dan nikmati minuman hangat.'; + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: scoreColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: scoreColor, + ), + ), + ), + const Spacer(), + Text( + '$score / 42 Poin', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Text( + 'Tingkat Keparahan: ', + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textSecondary), + ), + Text( + severityIndo, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: scoreColor, + ), + ), + ], + ), + const SizedBox(height: 14), + const Divider(height: 1, color: Color(0xFFEADBFF)), + const SizedBox(height: 14), + Text( + 'PENJELASAN INDIKATOR:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + Text( + definition, + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textPrimary, height: 1.45), + ), + const SizedBox(height: 14), + Text( + 'REKOMENDASI MANDIRI:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Text( + tip, + style: GoogleFonts.inter(fontSize: 12.5, color: AppColors.textPrimary, height: 1.4), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Tutup'), + ), + ), + ], + ), + ), + ); + } + + static void showPsychologistReferral({ + required BuildContext context, + required String label, + required int score, + required String scaleType, + required VoidCallback onOpenWhatsapp, + }) { + String detailExplanation = ''; + + if (scaleType == 'depression') { + detailExplanation = + 'Sistem mencatat tingkat Depresi pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini dicirikan oleh anhedonia total (kehilangan kemampuan merasakan hal positif), perasaan tidak berharga mendalam, serta keletihan fisik dan emosional yang signifikan.\n\n' + '⚠️ Catatan Penting: Eva AI adalah pendamping mandiri dan bukan pengganti diagnosis atau terapi medis klinis. Kamu sangat disarankan untuk berbicara langsung dengan Psikolog Klinis atau Psikiater.'; + } else if (scaleType == 'anxiety') { + detailExplanation = + 'Sistem mencatat tingkat Kecemasan pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini ditandai dengan serangan panik intens, detak jantung kencang tanpa alasan fisik, guncangan rasa takut akut, atau kekhawatiran melumpuhkan harian.\n\n' + '⚠️ Catatan Penting: Gejala kecemasan akut membutuhkan penanganan profesional medis. Sangat disarankan untuk segera menemui Psikolog Klinis atau Psikiater terdekat.'; + } else { + detailExplanation = + 'Sistem mencatat tingkat Stres pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini mengindikasikan burnout ekstrem, ketegangan saraf kronis, kelelahan energi berat, serta keputusasaan dalam menangani tekanan harian.\n\n' + '⚠️ Catatan Penting: Beban stres kronis dapat berdampak pada kesehatan fisik. Disarankan untuk mengambil jeda penuh dan melakukan sesi konseling bersama psikolog profesional.'; + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFEBEE), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Color(0xFFD32F2F), size: 28), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SANGAT BERAT (EXTREMELY SEVERE)', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFFD32F2F), + letterSpacing: 0.5, + ), + ), + Text( + 'Indikasi $label: $score / 42 Poin', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: const Color(0xFFB71C1C), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + 'PENJELASAN KLINIS & REKOMENDASI:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Text( + detailExplanation, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.5, + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(ctx); + onOpenWhatsapp(); + }, + icon: const Icon(Icons.chat_outlined, size: 20), + label: Text( + 'Konsultasi via WhatsApp Psikolog / Krisis', + style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF25D366), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text( + 'Tutup Penjelasan', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ), + ], + ), + ), + ); + } + + static void showGuideDialog(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Icon(Icons.info_outline, color: AppColors.primary, size: 22), + const SizedBox(width: 8), + Text( + 'Panduan Skala DASS-21', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'DASS-21 (Depression Anxiety Stress Scales) mengukur 3 indikator psikologis harian (0–42 poin per subskala) dari transkrip percakapanmu bersama Eva AI.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.45, + ), + ), + const SizedBox(height: 16), + Text( + 'KATEGORI WARNA & TINGKAT KEPARAHAN:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + _buildLegendRow('Normal', 'Kondisi emosi stabil & terkendali.', const Color(0xFF2E7D32)), + _buildLegendRow('Ringan (Mild)', 'Fluktuasi emosi ringan harian.', const Color(0xFFFBC02D)), + _buildLegendRow('Sedang (Moderate)', 'Mulai mengganggu kenyamanan & butuh jeda istirahat.', const Color(0xFFF57C00)), + _buildLegendRow('Berat (Severe)', 'Beban emosional terasa berat & perlu perhatian khusus.', const Color(0xFFE65100)), + _buildLegendRow('Sangat Berat (Extremely Severe)', 'Indikasi krisis — sangat disarankan konsultasi ke psikolog/psikiater.', const Color(0xFFD32F2F)), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Saya Mengerti'), + ), + ), + ], + ), + ), + ), + ); + } + + static Widget _buildLegendRow(String label, String desc, Color color) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 4), + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Expanded( + child: RichText( + text: TextSpan( + style: GoogleFonts.inter(fontSize: 12, color: AppColors.textPrimary, height: 1.35), + children: [ + TextSpan( + text: '$label: ', + style: TextStyle(fontWeight: FontWeight.w700, color: color), + ), + TextSpan(text: desc), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscales_card.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscales_card.dart new file mode 100644 index 0000000..7a8a526 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscales_card.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../core/utils/responsive_layout_helper.dart'; +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; +import '../../domain/entities/dass_assessment_entity.dart'; +import 'bouncing_button.dart'; + +/// Card rendering summary scores for the 3 DASS-21 subscales (Stress, Anxiety, Depression). +class DassSubscalesCard extends StatelessWidget { + final DASSAssessmentEntity assessment; + final Map<int, int> stagedScores; + final VoidCallback onInfoTap; + final void Function(String label, int score, String severityIndo, String scaleType) onSubscaleTap; + final void Function(String label, int score, String scaleType) onExtremelySevereTap; + + const DassSubscalesCard({ + super.key, + required this.assessment, + required this.stagedScores, + required this.onInfoTap, + required this.onSubscaleTap, + required this.onExtremelySevereTap, + }); + + static String getIndonesianSeverity(String scale, int score) { + if (scale == 'depression') { + if (score >= 28) return 'Sangat Berat'; + if (score >= 21) return 'Berat'; + if (score >= 14) return 'Sedang'; + if (score >= 10) return 'Ringan'; + return 'Normal'; + } else if (scale == 'anxiety') { + if (score >= 20) return 'Sangat Berat'; + if (score >= 15) return 'Berat'; + if (score >= 10) return 'Sedang'; + if (score >= 8) return 'Ringan'; + return 'Normal'; + } else { + if (score >= 34) return 'Sangat Berat'; + if (score >= 26) return 'Berat'; + if (score >= 19) return 'Sedang'; + if (score >= 15) return 'Ringan'; + return 'Normal'; + } + } + + static Color getScoreColor(String severityIndo) { + switch (severityIndo) { + case 'Sangat Berat': + return const Color(0xFFD32F2F); + case 'Berat': + return const Color(0xFFE65100); + case 'Sedang': + return const Color(0xFFF57C00); + case 'Ringan': + return const Color(0xFFFBC02D); + case 'Normal': + default: + return const Color(0xFF2E7D32); + } + } + + @override + Widget build(BuildContext context) { + int strRaw = 0, anxRaw = 0, depRaw = 0; + + for (final item in assessment.items) { + final s = stagedScores[item.itemId] ?? item.score; + if (item.scale == 'stress') strRaw += s; + if (item.scale == 'anxiety') anxRaw += s; + if (item.scale == 'depression') depRaw += s; + } + + final int stressScore = strRaw * 2; + final int anxietyScore = anxRaw * 2; + final int depressionScore = depRaw * 2; + + final stressSev = getIndonesianSeverity('stress', stressScore); + final anxietySev = getIndonesianSeverity('anxiety', anxietyScore); + final depressionSev = getIndonesianSeverity('depression', depressionScore); + + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Skor 3 Dimensi DASS-21', + style: GoogleFonts.plusJakartaSans( + fontSize: context.responsiveFont(14), + fontWeight: FontWeight.w700, + color: const Color(0xFF1E1B4B), + ), + ), + const SizedBox(width: 8), + InkWell( + onTap: onInfoTap, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Color(0xFFE8F0FE), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.info_outline, + size: 15, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + _buildSubscaleTile( + context, + 'Stres', + stressScore, + stressSev, + 'stress', + ), + const SizedBox(width: 8), + _buildSubscaleTile( + context, + 'Kecemasan', + anxietyScore, + anxietySev, + 'anxiety', + ), + const SizedBox(width: 8), + _buildSubscaleTile( + context, + 'Depresi', + depressionScore, + depressionSev, + 'depression', + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSubscaleTile( + BuildContext context, + String label, + int score, + String severityIndo, + String scaleType, + ) { + final scoreColor = getScoreColor(severityIndo); + final bool isExtremelySevere = severityIndo == 'Sangat Berat'; + + final Color bgColor = isExtremelySevere + ? const Color(0xFFD32F2F) + : scoreColor.withValues(alpha: 0.08); + + final Color labelColor = isExtremelySevere ? Colors.white70 : scoreColor; + + return Expanded( + child: BouncingButton( + onTap: () { + if (isExtremelySevere) { + onExtremelySevereTap(label, score, scaleType); + } else { + onSubscaleTap(label, score, severityIndo, scaleType); + } + }, + scaleFactor: 0.94, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isExtremelySevere + ? const Color(0xFFB71C1C) + : scoreColor.withValues(alpha: 0.25), + width: isExtremelySevere ? 1.5 : 1, + ), + boxShadow: isExtremelySevere + ? [ + BoxShadow( + color: const Color(0xFFD32F2F).withValues(alpha: 0.35), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isExtremelySevere) ...[ + const Icon(Icons.warning_amber_rounded, size: 12, color: Colors.white), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: labelColor, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$score', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w900, + color: isExtremelySevere ? Colors.white : scoreColor, + ), + ), + Text( + '/42', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w600, + color: isExtremelySevere ? Colors.white70 : AppColors.textLight, + ), + ), + ], + ), + const SizedBox(height: 2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: isExtremelySevere + ? Colors.white.withValues(alpha: 0.2) + : scoreColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + severityIndo, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 8.5, + fontWeight: FontWeight.w800, + color: isExtremelySevere ? Colors.white : scoreColor, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/data/datasources/diary_fallback_fixture.dart b/apps/eva_mobile/lib/features/diary/data/datasources/diary_fallback_fixture.dart new file mode 100644 index 0000000..c254537 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/data/datasources/diary_fallback_fixture.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +/// Fallback mock dataset for AI Diary Detail screen when no arguments or data are provided. +final Map<String, dynamic> kDefaultDiaryDetailFallback = { + 'title': 'Refleksi Harian & Evaluasi Ujian', + 'date': '24 Oktober 2023', + 'sessionCount': 3, + 'lastSessionTime': '21:45 PM', + 'moodTag': 'Cemas & Stres', + 'moodEmoji': '😰', + 'summary': + 'Kumulatif 3 sesi suara hari ini: Refleksi Pagi (kecemasan akademik), Curhat Sore (istirahat teh), dan Refleksi Malam (evaluasi jadwal). EVA menyintesis kemajuan emosional harianmu.', + 'riskWarning': { + 'detected': true, + 'type': 'anxiety', + 'title': 'Indikasi Anxiety & Stres Kumulatif Terdeteksi', + 'level': 'Tinggi (68%)', + 'message': + 'EVA mendeteksi akumulasi kecemasan dan stres pada 3 sesi percakapan hari ini. Jangan ragu mengambil waktu jeda istirahat.', + }, + 'aiInsight': + 'Kumulatif Hari Ini: Meskipun beban akademik memicu akumulasi rasa cemas pada Sesi #1 dan Sesi #3, kamu berhasil menenangkan diri pada Sesi #2 saat istirahat teh.', + 'importantEvents': [ + '[Sesi #1 - 09:15 AM] Sesi belajar pagi & kecemasan ujian tengah semester', + '[Sesi #2 - 16:30 PM] Minum teh hangat & jeda santai', + '[Sesi #3 - 21:45 PM] Mengatur ulang target jadwal perkuliahan', + ], + 'emotionalReflection': + 'Dinamika harian menunjukkan fluktuasi dari kecemasan tinggi di pagi hari, mereda di sore hari, dan kembali reflektif di malam hari.', + 'sessions': [ + { + 'id': 's1', + 'title': 'Sesi #1: Refleksi Pagi & Ujian', + 'time': '09:15 AM', + 'moodTag': 'Takut & Cemas', + 'moodEmoji': '😨', + 'emotionsBreakdown': [ + {'name': 'fear', 'label': 'Takut / Gelisah', 'emoji': '😨', 'percent': 0.65, 'color': const Color(0xFF6C63FF)}, + {'name': 'sadness', 'label': 'Sedih / Haru', 'emoji': '😔', 'percent': 0.20, 'color': const Color(0xFF8B93FF)}, + {'name': 'netral', 'label': 'Netral', 'emoji': '😐', 'percent': 0.15, 'color': const Color(0xFFA7E6FF)}, + ], + 'transcripts': [ + { + 'isUser': true, + 'time': '09:15 AM', + 'text': 'Saya sangat cemas dan takut tidak bisa menyelesaikan tugas kuliah ini dengan baik.', + 'emotionTag': 'fear (68%)', + 'emotionEmoji': '😨', + }, + { + 'isUser': false, + 'time': '09:16 AM', + 'text': + 'Aku mendengarmu, Sarah. Sangat wajar merasa cemas saat tugas menumpuk. Mari kita uraikan bersama menjadi langkah kecil ya.', + }, + ], + }, + { + 'id': 's2', + 'title': 'Sesi #2: Jeda Ketenangan Sore', + 'time': '16:30 PM', + 'moodTag': 'Tenang & Nyaman', + 'moodEmoji': '😌', + 'emotionsBreakdown': [ + {'name': 'netral', 'label': 'Netral', 'emoji': '😐', 'percent': 0.50, 'color': const Color(0xFFA7E6FF)}, + {'name': 'happy', 'label': 'Bahagia', 'emoji': '😃', 'percent': 0.35, 'color': const Color(0xFFFFE6A7)}, + {'name': 'fear', 'label': 'Takut / Gelisah', 'emoji': '😨', 'percent': 0.15, 'color': const Color(0xFF6C63FF)}, + ], + 'transcripts': [ + { + 'isUser': true, + 'time': '16:30 PM', + 'text': 'Saya baru saja minum teh dan berjalan santai sebentar. Rasanya sedikit lebih lega.', + 'emotionTag': 'netral (60%)', + 'emotionEmoji': '😐', + }, + { + 'isUser': false, + 'time': '16:31 PM', + 'text': + 'Itu langkah yang luar biasa! Memberikan waktu istirahat pada pikiran sangat penting untuk pemulihan energimu.', + }, + ], + }, + { + 'id': 's3', + 'title': 'Sesi #3: Evaluasi Malam & Jadwal', + 'time': '21:45 PM', + 'moodTag': 'Reflektif & Lelah', + 'moodEmoji': '😴', + 'emotionsBreakdown': [ + {'name': 'fear', 'label': 'Takut / Gelisah', 'emoji': '😨', 'percent': 0.40, 'color': const Color(0xFF6C63FF)}, + {'name': 'sadness', 'label': 'Sedih / Haru', 'emoji': '😔', 'percent': 0.35, 'color': const Color(0xFF8B93FF)}, + {'name': 'netral', 'label': 'Netral', 'emoji': '😐', 'percent': 0.25, 'color': const Color(0xFFA7E6FF)}, + ], + 'transcripts': [ + { + 'isUser': true, + 'time': '21:45 PM', + 'text': 'Malam ini saya merapikan ulang target besok agar tidak kaget lagi.', + 'emotionTag': 'sadness (55%)', + 'emotionEmoji': '😔', + }, + { + 'isUser': false, + 'time': '21:46 PM', + 'text': 'Langkah yang sangat bijak. Sekarang matikan gawai dan istirahatlah dengan tenang.', + }, + ], + }, + ], +}; diff --git a/apps/eva_mobile/lib/features/diary/data/datasources/mock_diary_datasource.dart b/apps/eva_mobile/lib/features/diary/data/datasources/mock_diary_datasource.dart index 940245e..4fd46ba 100644 --- a/apps/eva_mobile/lib/features/diary/data/datasources/mock_diary_datasource.dart +++ b/apps/eva_mobile/lib/features/diary/data/datasources/mock_diary_datasource.dart @@ -92,7 +92,7 @@ class MockDiaryDataSource implements DiaryDataSource { level: 'RISIKO TINGGI', message: 'Sistem EVA mendeteksi indikasi krisis emosional tinggi dan stres berat pada percakapan Samsul hari ini. Protokol keselamatan aktif untuk rujukan darurat.', ), - aiInsight: 'EVA AI mengaktifkan mode de-eskalasi dan menyajikan rujukan kontak krisis darurat (Hotline 119 ext 8).', + aiInsight: 'Eva AI mengaktifkan mode de-eskalasi dan menyajikan rujukan kontak krisis darurat (Hotline 119 ext 8).', importantEvents: [ '[Sesi #1] Samsul menyampaikan keluhan kelelahan fisik dan kecemasan mendalam.', '[Sesi #2] Terdeteksi puncak stres emosional tinggi — Protokol Krisis dipicu.', diff --git a/apps/eva_mobile/lib/features/diary/data/repositories/diary_repository_impl.dart b/apps/eva_mobile/lib/features/diary/data/repositories/diary_repository_impl.dart index cf696fb..7e11eaf 100644 --- a/apps/eva_mobile/lib/features/diary/data/repositories/diary_repository_impl.dart +++ b/apps/eva_mobile/lib/features/diary/data/repositories/diary_repository_impl.dart @@ -14,6 +14,12 @@ class DiaryRepositoryImpl implements DiaryRepository { @override Future<DiaryEntryEntity?> getDiaryEntryById(String id) => _dataSource.getDiaryEntryById(id); + @override + Future<DiaryEntryEntity?> getTodayDiary() => _dataSource.getTodayDiary(); + + @override + Future<DiaryEntryEntity?> generateTodayDiary() => _dataSource.generateTodayDiary(); + @override Future<DiaryEntryEntity> createDiaryEntry(String content, String moodTag) => _dataSource.createDiaryEntry(content, moodTag); diff --git a/apps/eva_mobile/lib/features/diary/data/repositories/fallback_diary_repository.dart b/apps/eva_mobile/lib/features/diary/data/repositories/fallback_diary_repository.dart new file mode 100644 index 0000000..b889852 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/data/repositories/fallback_diary_repository.dart @@ -0,0 +1,68 @@ +import '../../domain/repositories/diary_repository.dart'; +import '../../domain/entities/diary_entry_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackDiaryRepository implements DiaryRepository { + final DiaryRepository _remote; + final DiaryRepository _mock; + + FallbackDiaryRepository({ + required DiaryRepository remote, + required DiaryRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future<List<DiaryEntryEntity>> getDiaryEntries({String? moodFilter, String? searchQuery}) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getDiaryEntries(moodFilter: moodFilter, searchQuery: searchQuery), + mockCall: () => _mock.getDiaryEntries(moodFilter: moodFilter, searchQuery: searchQuery), + operationName: 'getDiaryEntries', + ); + } + + @override + Future<DiaryEntryEntity?> getDiaryEntryById(String id) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getDiaryEntryById(id), + mockCall: () => _mock.getDiaryEntryById(id), + operationName: 'getDiaryEntryById($id)', + ); + } + + @override + Future<DiaryEntryEntity?> getTodayDiary() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getTodayDiary(), + mockCall: () => _mock.getTodayDiary(), + operationName: 'getTodayDiary', + ); + } + + @override + Future<DiaryEntryEntity?> generateTodayDiary() { + return FallbackExecutor.execute( + remoteCall: () => _remote.generateTodayDiary(), + mockCall: () => _mock.generateTodayDiary(), + operationName: 'generateTodayDiary', + ); + } + + @override + Future<DiaryEntryEntity> createDiaryEntry(String content, String moodTag) { + return FallbackExecutor.execute( + remoteCall: () => _remote.createDiaryEntry(content, moodTag), + mockCall: () => _mock.createDiaryEntry(content, moodTag), + operationName: 'createDiaryEntry', + ); + } + + @override + Future<void> deleteDiaryEntry(String id) { + return FallbackExecutor.execute( + remoteCall: () => _remote.deleteDiaryEntry(id), + mockCall: () => _mock.deleteDiaryEntry(id), + operationName: 'deleteDiaryEntry($id)', + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/domain/repositories/diary_repository.dart b/apps/eva_mobile/lib/features/diary/domain/repositories/diary_repository.dart index 553bb8e..75ed272 100644 --- a/apps/eva_mobile/lib/features/diary/domain/repositories/diary_repository.dart +++ b/apps/eva_mobile/lib/features/diary/domain/repositories/diary_repository.dart @@ -3,6 +3,8 @@ import '../entities/diary_entry_entity.dart'; abstract class DiaryRepository { Future<List<DiaryEntryEntity>> getDiaryEntries({String? moodFilter, String? searchQuery}); Future<DiaryEntryEntity?> getDiaryEntryById(String id); + Future<DiaryEntryEntity?> getTodayDiary(); + Future<DiaryEntryEntity?> generateTodayDiary(); Future<DiaryEntryEntity> createDiaryEntry(String content, String moodTag); Future<void> deleteDiaryEntry(String id); } diff --git a/apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart b/apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart index 21493ea..de43116 100644 --- a/apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart +++ b/apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart @@ -1,16 +1,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/diary_repository.dart'; import '../../domain/entities/diary_entry_entity.dart'; import '../../data/datasources/mock_diary_datasource.dart'; import '../../data/datasources/remote_diary_datasource.dart'; import '../../data/repositories/diary_repository_impl.dart'; +import '../../data/repositories/fallback_diary_repository.dart'; final diaryRepositoryProvider = Provider<DiaryRepository>((ref) { + final mockRepo = DiaryRepositoryImpl(MockDiaryDataSource()); + final remoteRepo = DiaryRepositoryImpl(RemoteDiaryDataSource()); + if (AppConfig.useMockData) { - return DiaryRepositoryImpl(MockDiaryDataSource()); + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackDiaryRepository(remote: remoteRepo, mock: mockRepo); } else { - return DiaryRepositoryImpl(RemoteDiaryDataSource()); + return remoteRepo; } }); diff --git a/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_detail_screen.dart b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_detail_screen.dart new file mode 100644 index 0000000..5241a01 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_detail_screen.dart @@ -0,0 +1,509 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../../../widgets/staggered_entrance.dart'; +import '../../../chat/presentation/providers/chat_provider.dart'; +import '../../data/models/diary_entry_model.dart'; +import '../../data/datasources/diary_fallback_fixture.dart'; +import '../providers/diary_provider.dart'; +import '../widgets/diary_emotion_distribution_card.dart'; +import '../widgets/diary_insight_card.dart'; +import '../widgets/diary_key_events_card.dart'; +import '../widgets/diary_reflection_card.dart'; +import '../widgets/diary_transcript_section.dart'; + +/// Screen displaying comprehensive AI-synthesized diary details, cumulative daily insights, +/// session breakdowns, emotional reflections, and voice transcripts. +class AiDiaryDetailScreen extends ConsumerStatefulWidget { + final Map<String, dynamic>? journalData; + + const AiDiaryDetailScreen({ + super.key, + this.journalData, + }); + + @override + ConsumerState<AiDiaryDetailScreen> createState() => _AiDiaryDetailScreenState(); +} + +class _AiDiaryDetailScreenState extends ConsumerState<AiDiaryDetailScreen> { + late String _selectedSessionId; + bool _isInitialized = false; + Map<String, dynamic>? _diaryData; + + // Streaming and Skeleton State + bool _isStreaming = false; + bool _isLoadingSkeleton = false; + bool _streamError = false; + String? _streamedSummary; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + final args = widget.journalData ?? + (ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?); + if (args != null) { + _diaryData = Map<String, dynamic>.from(args); + _selectedSessionId = args['selectedSessionId']?.toString() ?? 'all'; + + final bool isSingle = _diaryData!['isSingleSession'] == true; + final dId = _diaryData!['id']; + + if (isSingle) { + final convId = _diaryData!['conversationId'] ?? dId; + final existingSummary = _diaryData!['summary']; + if (existingSummary == null || existingSummary.toString().trim().isEmpty) { + if (convId != null) { + _streamSummary(convId.toString()); + } + } else { + _streamedSummary = existingSummary.toString(); + } + } else if (dId != null) { + _fetchDiaryDetail(dId.toString()); + } + } else { + _selectedSessionId = 'all'; + } + _isInitialized = true; + } + } + + Future<void> _streamSummary(String conversationId) async { + if (_isStreaming) return; + + setState(() { + _isLoadingSkeleton = true; + _isStreaming = true; + _streamError = false; + _streamedSummary = null; + }); + + try { + final stream = ref.read(chatRepositoryProvider).streamConversationSummary(conversationId); + await for (final token in stream) { + if (!mounted) break; + setState(() { + _isLoadingSkeleton = false; + _streamedSummary = (_streamedSummary ?? '') + token; + }); + } + if (mounted) { + setState(() { + _isStreaming = false; + _isLoadingSkeleton = false; + if (_diaryData != null && _streamedSummary != null) { + _diaryData!['summary'] = _streamedSummary; + } + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isStreaming = false; + _isLoadingSkeleton = false; + _streamError = true; + }); + } + } + } + + Future<void> _fetchDiaryDetail(String diaryId) async { + try { + final repository = ref.read(diaryRepositoryProvider); + final entry = await repository.getDiaryEntryById(diaryId); + if (entry != null && mounted) { + setState(() { + if (entry is DiaryEntryModel) { + _diaryData = entry.toJson(); + } else { + _diaryData = { + 'id': entry.id, + 'title': entry.title, + 'date': entry.date, + 'sessionCount': entry.sessionCount, + 'lastSessionTime': entry.lastSessionTime, + 'moodTag': entry.moodTag, + 'moodEmoji': entry.moodEmoji, + 'summary': entry.summary, + 'aiInsight': entry.aiInsight, + 'importantEvents': entry.importantEvents, + 'emotionalReflection': entry.emotionalReflection, + 'sessions': entry.sessions.map((s) => { + 'id': s.id, + 'title': s.title, + 'time': s.time, + 'moodTag': s.moodTag, + 'moodEmoji': s.moodEmoji, + }).toList(), + }; + } + }); + } + } catch (_) { + // Graceful fallback to existing passed state or default fixture + } + } + + @override + Widget build(BuildContext context) { + final args = widget.journalData ?? + (ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?); + + final data = _diaryData ?? args ?? kDefaultDiaryDetailFallback; + final bool isSingleSession = data['isSingleSession'] == true; + final bool hasRisk = data['riskWarning'] != null && data['riskWarning']['detected'] == true; + + final List<dynamic> rawEvents = + data['importantEvents'] is List ? data['importantEvents'] as List : []; + final List<String> importantEvents = rawEvents.isNotEmpty + ? rawEvents.map((e) => e.toString()).toList() + : ['Sesi refleksi harian tercatat dalam sistem EVA.']; + + final List<Map<String, dynamic>> sessions = + (data['sessions'] is List && (data['sessions'] as List).isNotEmpty) + ? (data['sessions'] as List).map((e) => Map<String, dynamic>.from(e as Map)).toList() + : []; + + if (_selectedSessionId != 'all' && sessions.isNotEmpty) { + final exists = sessions.any((s) => s['id'].toString() == _selectedSessionId); + if (!exists) { + _selectedSessionId = 'all'; + } + } + + // Compute active emotions breakdown based on selected session + List<Map<String, dynamic>> activeEmotions = []; + if (_selectedSessionId == 'all') { + if (data['emotionsBreakdown'] is List && (data['emotionsBreakdown'] as List).isNotEmpty) { + activeEmotions = (data['emotionsBreakdown'] as List) + .map((e) => Map<String, dynamic>.from(e as Map)) + .toList(); + } else { + activeEmotions = [ + {'name': 'fear', 'label': 'Takut / Gelisah', 'emoji': '😨', 'percent': 0.45, 'color': const Color(0xFF6C63FF)}, + {'name': 'sadness', 'label': 'Sedih / Haru', 'emoji': '😔', 'percent': 0.25, 'color': const Color(0xFF8B93FF)}, + {'name': 'netral', 'label': 'Netral', 'emoji': '😐', 'percent': 0.15, 'color': const Color(0xFFA7E6FF)}, + {'name': 'happy', 'label': 'Bahagia', 'emoji': '😃', 'percent': 0.10, 'color': const Color(0xFFFFE6A7)}, + {'name': 'surprise', 'label': 'Terkejut', 'emoji': '😲', 'percent': 0.03, 'color': const Color(0xFFC3B8FF)}, + {'name': 'anger', 'label': 'Marah', 'emoji': '😡', 'percent': 0.01, 'color': const Color(0xFFFFB6C1)}, + {'name': 'disgusted', 'label': 'Jijik / Muak', 'emoji': '🤢', 'percent': 0.01, 'color': const Color(0xFFB8C2FC)}, + ]; + } + } else { + Map<String, dynamic>? selectedSessData; + for (var s in sessions) { + if (s['id'].toString() == _selectedSessionId) { + selectedSessData = s; + break; + } + } + if (selectedSessData == null && sessions.isNotEmpty) { + selectedSessData = sessions.first; + } + + if (selectedSessData != null && + selectedSessData['emotionsBreakdown'] is List && + (selectedSessData['emotionsBreakdown'] as List).isNotEmpty) { + activeEmotions = (selectedSessData['emotionsBreakdown'] as List) + .map((e) => Map<String, dynamic>.from(e as Map)) + .toList(); + } else { + activeEmotions = [ + {'name': 'netral', 'label': 'Ketenangan & Refleksi', 'emoji': '😌', 'percent': 0.60, 'color': const Color(0xFFA7E6FF)}, + {'name': 'happy', 'label': 'Lega & Nyaman', 'emoji': '😃', 'percent': 0.40, 'color': const Color(0xFFFFE6A7)}, + ]; + } + } + + // Compute active transcripts list based on selected session + List<Map<String, dynamic>> activeTranscripts = []; + if (_selectedSessionId == 'all') { + for (var s in sessions) { + final rawTrans = s['transcripts']; + if (rawTrans is List) { + activeTranscripts.addAll( + rawTrans.map((t) => Map<String, dynamic>.from(t as Map)), + ); + } + } + } else { + for (var s in sessions) { + if (s['id'].toString() == _selectedSessionId) { + final rawTrans = s['transcripts']; + if (rawTrans is List) { + activeTranscripts.addAll( + rawTrans.map((t) => Map<String, dynamic>.from(t as Map)), + ); + } + break; + } + } + } + + return Scaffold( + backgroundColor: const Color(0xFFF9F9FB), + body: SafeArea( + child: Column( + children: [ + // Top App Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data['title']?.toString() ?? 'Detail Jurnal Refleksi', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + isSingleSession + ? '${data['date'] ?? 'Hari ini'} • ${data['moodTag'] ?? 'Tenang & Nyaman'}' + : '${data['date'] ?? 'Hari ini'} • ${sessions.length} Sesi Suara', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1, color: Color(0xFFEBECEF)), + + // Main Body Content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // MENTAL HEALTH ALERT CARD (If Risk Detected) + if (hasRisk) ...[ + StaggeredEntrance( + index: 1, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFFFF0F2), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: const Color(0xFFE57373).withValues(alpha: 0.4), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFFE57373).withValues(alpha: 0.1), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFE57373), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.crisis_alert, color: Colors.white, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + data['riskWarning']['title']?.toString() ?? + 'Indikasi Distress Terdeteksi', + style: GoogleFonts.inter( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: const Color(0xFFC62828), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + data['riskWarning']['message']?.toString() ?? + 'Pola emosi kumulatif menunjukkan beban emosional yang tinggi.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + ], + + // 1. WAWASAN AI KUMULATIF / RINGKASAN SESI + DiaryInsightCard( + isSingleSession: isSingleSession, + sessionCount: sessions.length, + lastSessionTime: data['lastSessionTime']?.toString(), + isLoadingSkeleton: _isLoadingSkeleton, + isStreaming: _isStreaming, + streamError: _streamError, + streamedSummary: _streamedSummary, + summaryText: (isSingleSession + ? (_streamedSummary ?? data['summary'] ?? data['aiInsight']) + : data['aiInsight']) + ?.toString(), + onRetry: () { + final convId = data['conversationId'] ?? data['id']; + if (convId != null) { + _streamSummary(convId.toString()); + } + }, + ), + const SizedBox(height: 16), + + if (!isSingleSession) ...[ + StaggeredEntrance( + index: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 2. PERISTIWA PENTING KUMULATIF + DiaryKeyEventsCard(events: importantEvents), + const SizedBox(height: 16), + + // 3. REFLEKSI EMOSIONAL + DiaryReflectionCard( + reflectionText: data['emotionalReflection']?.toString(), + ), + const SizedBox(height: 24), + ], + ), + ), + ], + + StaggeredEntrance( + index: 4, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // SESSION SELECTOR CHIPS SECTION + if (!isSingleSession && sessions.isNotEmpty) ...[ + Text( + 'PILIH SESI UNTUK PENGURAIAN SPESIFIK', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 10), + SizedBox( + height: 46, + child: ListView( + clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(vertical: 3), + scrollDirection: Axis.horizontal, + children: [ + ChoiceChip( + showCheckmark: false, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + label: Text('Semua Sesi (${sessions.length})'), + selected: _selectedSessionId == 'all', + selectedColor: AppColors.primaryContainer, + backgroundColor: Colors.white.withValues(alpha: 0.9), + labelStyle: GoogleFonts.inter( + fontSize: 12, + fontWeight: _selectedSessionId == 'all' + ? FontWeight.w700 + : FontWeight.w500, + color: _selectedSessionId == 'all' + ? AppColors.primary + : AppColors.textSecondary, + ), + onSelected: (_) => setState(() => _selectedSessionId = 'all'), + ), + const SizedBox(width: 8), + ...sessions.map((sess) { + final sId = sess['id']?.toString() ?? ''; + final isSel = _selectedSessionId == sId; + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ChoiceChip( + showCheckmark: false, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + label: Text(sess['title']?.toString() ?? 'Sesi'), + selected: isSel, + selectedColor: AppColors.primaryContainer, + backgroundColor: Colors.white.withValues(alpha: 0.9), + labelStyle: GoogleFonts.inter( + fontSize: 12, + fontWeight: isSel ? FontWeight.w700 : FontWeight.w500, + color: isSel ? AppColors.primary : AppColors.textSecondary, + ), + onSelected: (_) => setState(() => _selectedSessionId = sId), + ), + ); + }), + ], + ), + ), + const SizedBox(height: 20), + ], + + // 4. ANALISIS EMOSI PER SESI (7 Parameter Emosi Filtered) + DiaryEmotionDistributionCard( + activeEmotions: activeEmotions, + isSingleSession: isSingleSession, + selectedSessionId: _selectedSessionId, + ), + ], + ), + ), + const SizedBox(height: 20), + + // 5. TRANSKRIP PERCAKAPAN SUARA + DiaryTranscriptSection( + transcripts: activeTranscripts, + isSingleSession: isSingleSession, + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_screen.dart b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_screen.dart new file mode 100644 index 0000000..e2b881d --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_screen.dart @@ -0,0 +1,734 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/diary/data/models/diary_entry_model.dart'; +import 'package:eva_mobile/features/diary/presentation/providers/diary_provider.dart'; +import 'package:eva_mobile/services/daily_progress_local_service.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/skeleton_shimmer.dart'; + +class AiDiaryScreen extends ConsumerStatefulWidget { + final GlobalKey? firstCardKey; + + const AiDiaryScreen({ + super.key, + this.firstCardKey, + }); + + @override + ConsumerState<AiDiaryScreen> createState() => AiDiaryScreenState(); +} + +class AiDiaryScreenState extends ConsumerState<AiDiaryScreen> { + late final GlobalKey _defaultFirstCardKey = GlobalKey(debugLabel: 'firstJournalCard'); + GlobalKey get firstJournalCardKey => widget.firstCardKey ?? _defaultFirstCardKey; + final TextEditingController _searchController = TextEditingController(); + + String _selectedFilter = 'Semua'; + + + + final List<String> _filterCategories = const [ + + 'Semua', + + 'Tenang 😌', + + 'Cemas 😰', + + 'Bahagia 😃', + + 'Sedih 😔', + + 'Stres 💥', + + ]; + + + + late List<Map<String, dynamic>> _entries; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _entries = []; + _fetchRemoteDiaries(); + } + + void refresh() => _fetchRemoteDiaries(); + + Future<void> _fetchRemoteDiaries() async { + if (mounted) setState(() => _isLoading = true); + try { + final repository = ref.read(diaryRepositoryProvider); + final entries = await repository.getDiaryEntries(); + if (mounted) { + setState(() { + _entries = entries.map((e) { + if (e is DiaryEntryModel) return e.toJson(); + return { + 'id': e.id, + 'title': e.title, + 'date': e.date, + 'sessionCount': e.sessionCount, + 'lastSessionTime': e.lastSessionTime, + 'moodTag': e.moodTag, + 'moodEmoji': e.moodEmoji, + 'summary': e.summary, + 'aiInsight': e.aiInsight, + 'importantEvents': e.importantEvents, + 'emotionalReflection': e.emotionalReflection, + 'sessions': e.sessions.map((s) => { + 'id': s.id, + 'title': s.title, + 'time': s.time, + 'moodTag': s.moodTag, + 'moodEmoji': s.moodEmoji, + }).toList(), + }; + }).toList(); + }); + } + final todayStr = DailyProgressLocalService.getTodayWibDate(); + final hasTodayEntry = _entries.any((e) { + final date = e['date']?.toString() ?? e['created_at']?.toString() ?? ''; + return date.startsWith(todayStr); + }); + if (hasTodayEntry) { + DailyProgressLocalService.recordDiaryCheckin(hasDiary: true); + } + } catch (_) { + // Keep existing entries or empty + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final query = _searchController.text.toLowerCase(); + final filteredList = _entries.where((item) { + final matchesQuery = query.isEmpty || + item['title'].toString().toLowerCase().contains(query) || + item['summary'].toString().toLowerCase().contains(query) || + item['moodTag'].toString().toLowerCase().contains(query); + + + + final matchesFilter = _selectedFilter == 'Semua' || + + item['moodTag'].toString().toLowerCase().contains(_selectedFilter.split(' ').first.toLowerCase()); + + + + return matchesQuery && matchesFilter; + + }).toList(); + + + + return Scaffold( + + body: Container( + + width: double.infinity, + + height: double.infinity, + + decoration: const BoxDecoration( + + gradient: LinearGradient( + + colors: [ + + Color(0xFFF6F8FF), + + Color(0xFFEFF2FE), + + Color(0xFFF8F9FE), + + ], + + begin: Alignment.topCenter, + + end: Alignment.bottomCenter, + + ), + + ), + + child: SafeArea( + + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: _fetchRemoteDiaries, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + + padding: const EdgeInsets.fromLTRB(20.0, 16.0, 20.0, 100.0), + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + // Title Header + + Text( + + 'Jurnal AI Harian & Sesi', + + style: GoogleFonts.inter( + + fontSize: 24, + + fontWeight: FontWeight.w800, + + color: AppColors.textPrimary, + + ), + + ), + + const SizedBox(height: 4), + + Text( + 'Rangkuman refleksi emosi dan transkrip suara.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + + const SizedBox(height: 20), + + + + // Search Bar Field + + Container( + + decoration: BoxDecoration( + + color: Colors.white.withValues(alpha: 0.9), + + borderRadius: BorderRadius.circular(999), + + boxShadow: [ + + BoxShadow( + + color: Colors.black.withValues(alpha: 0.04), + + blurRadius: 16, + + offset: const Offset(0, 4), + + ), + + ], + + ), + + child: TextField( + + controller: _searchController, + + onChanged: (_) => setState(() {}), + + style: GoogleFonts.inter(fontSize: 14, color: AppColors.textPrimary), + + decoration: InputDecoration( + + hintText: 'Cari jurnal atau kata kunci emosi...', + + hintStyle: GoogleFonts.inter(fontSize: 13, color: AppColors.textLight), + + prefixIcon: const Icon(Icons.search, color: AppColors.primary, size: 20), + + suffixIcon: query.isNotEmpty + + ? IconButton( + + icon: const Icon(Icons.clear, size: 18), + + onPressed: () { + + _searchController.clear(); + + setState(() {}); + + }, + + ) + + : null, + + border: InputBorder.none, + + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + + ), + + ), + + ), + + const SizedBox(height: 16), + + + + // Filter Category Chips + SizedBox( + height: 46, + child: ListView.separated( + clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(vertical: 3), + scrollDirection: Axis.horizontal, + itemCount: _filterCategories.length, + separatorBuilder: (context, index) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final category = _filterCategories[index]; + final isSelected = category == _selectedFilter; + return ChoiceChip( + showCheckmark: false, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + label: Text(category), + + selected: isSelected, + + selectedColor: AppColors.primaryContainer, + + backgroundColor: Colors.white.withValues(alpha: 0.8), + + labelStyle: GoogleFonts.inter( + + fontSize: 12, + + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + + color: isSelected ? AppColors.primary : AppColors.textSecondary, + + ), + + shape: RoundedRectangleBorder( + + borderRadius: BorderRadius.circular(999), + + side: BorderSide( + + color: isSelected ? AppColors.primary : Colors.transparent, + + ), + + ), + + onSelected: (selected) { + + setState(() { + + _selectedFilter = category; + + }); + + }, + + ); + + }, + + ), + + ), + + const SizedBox(height: 20), + + // Journal History Cards List / Skeleton Loader + if (_isLoading && _entries.isEmpty) + _DiaryListSkeleton(firstCardKey: firstJournalCardKey) + else if (filteredList.isEmpty) + if (_entries.isEmpty && !_isLoading) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 48.0, horizontal: 16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.menu_book_outlined, + size: 42, + color: AppColors.primary, + ), + ), + const SizedBox(height: 16), + Text( + 'Belum Ada Jurnal Refleksi', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'Ceritakan harimu atau lakukan panggilan suara dengan EVA. Setelah sesi selesai, jurnal refleksi emosimu akan otomatis tersintesis dan tersimpan di sini.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textLight, + height: 1.45, + ), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: () { + Navigator.pushNamed(context, '/call'); + }, + icon: const Icon(Icons.phone_in_talk, size: 18), + label: const Text('Mulai Bicara dengan EVA'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + ), + ], + ), + ), + ) + else + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40.0), + child: Column( + children: [ + const Icon(Icons.search_off, size: 48, color: AppColors.textLight), + const SizedBox(height: 12), + Text( + 'Tidak ada jurnal yang sesuai', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ) + + else + + ListView.separated( + + shrinkWrap: true, + + physics: const NeverScrollableScrollPhysics(), + + itemCount: filteredList.length, + + separatorBuilder: (context, index) => const SizedBox(width: 0, height: 14), + + itemBuilder: (context, index) { + + final item = filteredList[index]; + + final hasRisk = item['riskWarning'] != null && + + item['riskWarning']['detected'] == true; + + + + return GlassCard( + key: index == 0 ? firstJournalCardKey : null, + width: double.infinity, + + onTap: () { + + Navigator.pushNamed( + + context, + + '/diary_detail', + + arguments: item, + + ); + + }, + + padding: const EdgeInsets.all(18), + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + Row( + + children: [ + + Text(item['moodEmoji'], style: const TextStyle(fontSize: 18)), + + const SizedBox(width: 8), + + Expanded( + + child: Text( + + item['title'], + + maxLines: 1, + + overflow: TextOverflow.ellipsis, + + style: GoogleFonts.inter( + + fontSize: 15, + + fontWeight: FontWeight.w700, + + color: AppColors.textPrimary, + + ), + + ), + + ), + + const SizedBox(width: 8), + + const Icon( + + Icons.arrow_forward_ios, + + size: 14, + + color: AppColors.textLight, + + ), + + ], + + ), + + const SizedBox(height: 6), + + Wrap( + + crossAxisAlignment: WrapCrossAlignment.center, + + spacing: 8, + + runSpacing: 4, + + children: [ + + Text( + + item['date'], + + style: GoogleFonts.inter( + + fontSize: 12, + + color: AppColors.textLight, + + ), + + ), + + + if (hasRisk) + + Container( + + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + + decoration: BoxDecoration( + + color: const Color(0xFFFFDCDD), + + borderRadius: BorderRadius.circular(999), + + ), + + child: Text( + + '⚠️ PERINGATAN MENTAL HEALTH', + + style: GoogleFonts.inter( + + fontSize: 9, + + fontWeight: FontWeight.w800, + + color: const Color(0xFFD32F2F), + + ), + + ), + + ), + + ], + + ), + + const SizedBox(height: 10), + + Text( + + item['summary'], + + maxLines: 2, + + overflow: TextOverflow.ellipsis, + + style: GoogleFonts.inter( + + fontSize: 13, + + color: AppColors.textSecondary, + + height: 1.4, + + ), + + ), + + const SizedBox(height: 12), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFEADBFF), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + item['moodTag'], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ), + ), + const SizedBox(width: 8), + Text( + 'Lihat Detail Sesi →', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + + ], + + ), + + ); + + }, + + ), + + const SizedBox(height: 24), + + ], + + ), + + ), + ), + ), + ), + ); + + } +} + +/// Shimmer skeleton loader for the AI Diary list. +class _DiaryListSkeleton extends StatelessWidget { + final GlobalKey? firstCardKey; + const _DiaryListSkeleton({this.firstCardKey}); + + @override + Widget build(BuildContext context) { + return SkeletonShimmerHost( + child: Column( + children: List.generate( + 3, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 14.0), + child: GlassCard( + key: index == 0 ? firstCardKey : null, + width: double.infinity, + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SkeletonCircle(size: 24), + const SizedBox(width: 10), + const SkeletonLine(width: 130, height: 16), + const Spacer(), + const SkeletonBox(width: 65, height: 22, borderRadius: 12), + ], + ), + const SizedBox(height: 14), + const SkeletonLine(width: double.infinity, height: 13), + const SizedBox(height: 8), + const SkeletonLine(width: 220, height: 13), + const SizedBox(height: 14), + Row( + children: const [ + SkeletonBox(width: 75, height: 24, borderRadius: 8), + SizedBox(width: 8), + SkeletonBox(width: 90, height: 24, borderRadius: 8), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_emotion_distribution_card.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_emotion_distribution_card.dart new file mode 100644 index 0000000..25ef94e --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_emotion_distribution_card.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +/// Helper to parse color objects or hex string fallback. +Color parseEmotionColor(dynamic colorVal, Color fallback) { + if (colorVal is Color) return colorVal; + if (colorVal is String && colorVal.startsWith('#')) { + final hex = colorVal.replaceAll('#', ''); + if (hex.length == 6) { + return Color(int.parse('FF$hex', radix: 16)); + } + } + return fallback; +} + +/// Card rendering the 7-emotion distribution parameters with animated percentage progress bars. +class DiaryEmotionDistributionCard extends StatelessWidget { + final List<Map<String, dynamic>> activeEmotions; + final bool isSingleSession; + final String selectedSessionId; + + const DiaryEmotionDistributionCard({ + super.key, + required this.activeEmotions, + required this.isSingleSession, + required this.selectedSessionId, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'ANALISIS 7 PARAMETER EMOSI', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFEADBFF), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + isSingleSession + ? 'SESI INI' + : (selectedSessionId == 'all' ? 'KUMULATIF' : 'SESI SPESIFIK'), + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + children: activeEmotions.map((emo) { + final double pct = + ((emo['percent'] as num?)?.toDouble() ?? 0.0).clamp(0.0, 1.0); + final int pctInt = (pct * 100).round(); + final Color emoColor = parseEmotionColor(emo['color'], AppColors.primary); + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + emo['emoji']?.toString() ?? '✨', + style: const TextStyle(fontSize: 16), + ), + const SizedBox(width: 8), + Text( + emo['label']?.toString() ?? 'Emosi', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + Text( + '$pctInt%', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: pct, + minHeight: 7, + backgroundColor: const Color(0xFFE2E4F0), + valueColor: AlwaysStoppedAnimation<Color>(emoColor), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_insight_card.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_insight_card.dart new file mode 100644 index 0000000..2059d44 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_insight_card.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; +import '../../../../widgets/staggered_entrance.dart'; +import 'summary_skeleton_loader.dart'; + +/// Card presenting AI-synthesized cumulative insights or standalone session summary. +class DiaryInsightCard extends StatelessWidget { + final bool isSingleSession; + final int sessionCount; + final String? lastSessionTime; + final bool isLoadingSkeleton; + final bool isStreaming; + final bool streamError; + final String? streamedSummary; + final String? summaryText; + final VoidCallback? onRetry; + + const DiaryInsightCard({ + super.key, + required this.isSingleSession, + required this.sessionCount, + this.lastSessionTime, + this.isLoadingSkeleton = false, + this.isStreaming = false, + this.streamError = false, + this.streamedSummary, + this.summaryText, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return StaggeredEntrance( + index: 2, + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.auto_awesome, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isSingleSession ? 'RINGKASAN PERCAKAPAN' : 'WAWASAN AI KUMULATIF', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + letterSpacing: 0.8, + ), + ), + Text( + isSingleSession + ? '✨ Ringkasan cerdas percakapan sesi suara bersama EVA' + : '✨ Diperbarui setelah Sesi #$sessionCount (${lastSessionTime ?? 'Hari ini'})', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + if (isSingleSession && isLoadingSkeleton) + const SummarySkeletonLoader() + else if (isSingleSession && isStreaming) + RichText( + text: TextSpan( + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textPrimary, + height: 1.5, + ), + children: [ + TextSpan(text: streamedSummary ?? ''), + const TextSpan( + text: ' ▋', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ) + else if (isSingleSession && + streamError && + (streamedSummary == null || streamedSummary!.isEmpty)) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ringkasan percakapan belum berhasil dimuat.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textLight, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 10), + if (onRetry != null) + InkWell( + onTap: onRetry, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.auto_awesome, size: 14, color: AppColors.primary), + const SizedBox(width: 6), + Text( + '✨ Generate Ringkasan', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ], + ) + else + Text( + summaryText ?? + 'Analisis AI menunjukkan kondisi emosional kamu hari ini cukup stabil.', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textPrimary, + height: 1.5, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_key_events_card.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_key_events_card.dart new file mode 100644 index 0000000..b24b25c --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_key_events_card.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +/// Card displaying cumulative key events and timestamps for the diary entry. +class DiaryKeyEventsCard extends StatelessWidget { + final List<String> events; + + const DiaryKeyEventsCard({ + super.key, + required this.events, + }); + + @override + Widget build(BuildContext context) { + return GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFF489BB8), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.calendar_today_outlined, + color: Colors.white, + size: 18, + ), + ), + const SizedBox(width: 10), + Text( + 'PERISTIWA PENTING HARIAN', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF20667B), + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 14), + ...events.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 6), + width: 6, + height: 6, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + item, + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_reflection_card.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_reflection_card.dart new file mode 100644 index 0000000..5e7f48e --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_reflection_card.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +/// Card displaying cognitive and emotional reflection for the diary entry. +class DiaryReflectionCard extends StatelessWidget { + final String? reflectionText; + + const DiaryReflectionCard({ + super.key, + this.reflectionText, + }); + + @override + Widget build(BuildContext context) { + return GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFF605A79), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.psychology_outlined, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 10), + Text( + 'REFLEKSI EMOSIONAL HARIAN', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF605A79), + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + reflectionText ?? + 'Merasa lebih tenang dan terarah setelah berefleksi bersama EVA.', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textPrimary, + height: 1.5, + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_transcript_section.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_transcript_section.dart new file mode 100644 index 0000000..7e0f32a --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_transcript_section.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/staggered_entrance.dart'; + +/// Section rendering voice call transcripts with user/EVA speech bubbles and expand/collapse support. +class DiaryTranscriptSection extends StatefulWidget { + final List<Map<String, dynamic>> transcripts; + final bool isSingleSession; + final int initialLimit; + + const DiaryTranscriptSection({ + super.key, + required this.transcripts, + this.isSingleSession = false, + this.initialLimit = 4, + }); + + @override + State<DiaryTranscriptSection> createState() => _DiaryTranscriptSectionState(); +} + +class _DiaryTranscriptSectionState extends State<DiaryTranscriptSection> { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + final transcripts = widget.transcripts; + final bool canExpand = transcripts.length > widget.initialLimit; + final int displayCount = (_isExpanded || !canExpand) + ? transcripts.length + : widget.initialLimit; + + return StaggeredEntrance( + index: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'TRANSKRIP SUARA', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + canExpand && !_isExpanded + ? '$displayCount dari ${transcripts.length}' + : '${transcripts.length} Dialog', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + if (transcripts.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + children: [ + const Icon(Icons.forum_outlined, size: 36, color: Color(0xFFB0B7C3)), + const SizedBox(height: 8), + Text( + 'Tidak ada transkrip rekaman suara tersimpan untuk sesi ini', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ) + else ...[ + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: displayCount, + itemBuilder: (context, index) { + final item = transcripts[index]; + final bool isUser = item['isUser'] as bool; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isUser + ? const Color(0xFFF0F2FF) + : Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isUser + ? AppColors.primary.withValues(alpha: 0.2) + : Colors.grey.shade200, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isUser + ? AppColors.primary + : const Color(0xFFEADBFF), + shape: BoxShape.circle, + ), + child: Icon( + isUser ? Icons.person : Icons.auto_awesome, + size: 13, + color: isUser ? Colors.white : AppColors.primary, + ), + ), + const SizedBox(width: 8), + Text( + isUser ? 'Kamu' : 'EVA AI', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + if (item['emotionTag'] != null && + item['emotionTag'].toString().isNotEmpty) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFEADBFF), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + item['emotionTag'].toString(), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 10), + Text( + item['text']?.toString() ?? '', + style: GoogleFonts.inter( + fontSize: 13.5, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + if (!widget.isSingleSession && + item['time'] != null && + item['time'].toString().isNotEmpty) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.bottomRight, + child: Text( + item['time']?.toString() ?? '', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textLight, + ), + ), + ), + ], + ], + ), + ); + }, + ), + if (canExpand) ...[ + const SizedBox(height: 4), + InkWell( + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + borderRadius: BorderRadius.circular(14), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.25), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.06), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _isExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + color: AppColors.primary, + size: 20, + ), + const SizedBox(width: 6), + Text( + _isExpanded + ? 'Tampilkan Lebih Sedikit' + : 'Lihat Selengkapnya (${transcripts.length - widget.initialLimit} pesan lainnya)', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ], + ], + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/widgets/summary_skeleton_loader.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/summary_skeleton_loader.dart new file mode 100644 index 0000000..c4140be --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/summary_skeleton_loader.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme/app_colors.dart'; + +/// Shimmer skeleton loader for AI Diary summary content. +class SummarySkeletonLoader extends StatefulWidget { + const SummarySkeletonLoader({super.key}); + + @override + State<SummarySkeletonLoader> createState() => _SummarySkeletonLoaderState(); +} + +class _SummarySkeletonLoaderState extends State<SummarySkeletonLoader> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation<double> _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + _animation = Tween<double>(begin: 0.25, end: 0.65).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final opacity = _animation.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + height: 14, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: opacity * 0.4), + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 10), + Container( + width: MediaQuery.of(context).size.width * 0.78, + height: 14, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: opacity * 0.3), + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 10), + Container( + width: MediaQuery.of(context).size.width * 0.52, + height: 14, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: opacity * 0.2), + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ); + }, + ); + } +} diff --git a/apps/eva_mobile/lib/features/emergency/presentation/screens/emergency_contacts_screen.dart b/apps/eva_mobile/lib/features/emergency/presentation/screens/emergency_contacts_screen.dart new file mode 100644 index 0000000..f365b85 --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/screens/emergency_contacts_screen.dart @@ -0,0 +1,707 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/auth/domain/entities/emergency_contact_entity.dart'; +import 'package:eva_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:eva_mobile/features/emergency/presentation/widgets/contact_form_dialog.dart'; +import 'package:eva_mobile/features/emergency/presentation/widgets/emergency_contacts_skeleton_loader.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class EmergencyContactsScreen extends ConsumerStatefulWidget { + const EmergencyContactsScreen({super.key}); + + @override + ConsumerState<EmergencyContactsScreen> createState() => _EmergencyContactsScreenState(); +} + +class _EmergencyContactsScreenState extends ConsumerState<EmergencyContactsScreen> { + List<Map<String, dynamic>> _contacts = []; + bool _isLoading = false; + + void _copyToClipboard(String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + AppSnackBar.showSuccess( + context, + '$label ($text) berhasil disalin ke clipboard', + duration: const Duration(seconds: 2), + ); + } + + @override + void initState() { + super.initState(); + _loadContacts(); + } + + Future<void> _loadContacts() async { + setState(() => _isLoading = true); + try { + final contacts = await ref.read(authRepositoryProvider).getEmergencyContacts(); + if (mounted) { + setState(() { + _contacts = contacts.map<Map<String, dynamic>>((c) => { + 'id': c.id, + 'name': c.name, + 'relation': c.relationship.isEmpty ? 'Kontak Darurat' : c.relationship, + 'phone': c.phone, + 'isPrimary': c.isPrimary, + }).toList(); + }); + } + } catch (_) {} + finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future<void> _saveContactToBackend({ + required String name, + required String relation, + required String phone, + required bool isPrimary, + Map<String, dynamic>? contactToEdit, + }) async { + setState(() { + if (isPrimary) { + for (var c in _contacts) { + c['isPrimary'] = false; + } + } + if (contactToEdit != null) { + contactToEdit['name'] = name; + contactToEdit['relation'] = relation; + contactToEdit['phone'] = phone; + contactToEdit['isPrimary'] = isPrimary; + } else { + _contacts.add({ + 'id': DateTime.now().millisecondsSinceEpoch.toString(), + 'name': name, + 'relation': relation.isEmpty ? 'Kontak Darurat' : relation, + 'phone': phone, + 'isPrimary': isPrimary || _contacts.isEmpty, + }); + } + }); + + try { + await ref.read(authRepositoryProvider).addEmergencyContact( + EmergencyContactEntity( + id: contactToEdit != null ? (contactToEdit['id']?.toString() ?? '') : '', + name: name, + relationship: relation.isEmpty ? 'Keluarga' : relation, + phone: phone, + isPrimary: isPrimary, + ), + ); + _loadContacts(); + } catch (_) {} + } + + Future<void> _deleteContactFromBackend(Map<String, dynamic> contact) async { + final contactId = contact['id']?.toString(); + setState(() { + _contacts.removeWhere((c) => c['id'] == contact['id']); + if (contact['isPrimary'] == true && _contacts.isNotEmpty) { + _contacts.first['isPrimary'] = true; + } + }); + + if (contactId != null && contactId.isNotEmpty) { + try { + await ref.read(authRepositoryProvider).deleteEmergencyContact(contactId); + } catch (_) {} + } + } + + + + void _showContactFormDialog({Map<String, dynamic>? contactToEdit}) { + ContactFormDialog.show( + context: context, + contactToEdit: contactToEdit, + onSave: ({ + required String name, + required String relation, + required String phone, + required bool isPrimary, + Map<String, dynamic>? contactToEdit, + }) { + _saveContactToBackend( + name: name, + relation: relation, + phone: phone, + isPrimary: isPrimary, + contactToEdit: contactToEdit, + ); + }, + ); + } + + void _confirmDeleteContact(Map<String, dynamic> contact) { + + showDialog( + + context: context, + + builder: (context) { + + return AlertDialog( + + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + + title: Text( + + 'Hapus Kontak Darurat?', + + style: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w700), + + ), + + content: Text( + + 'Apakah Anda yakin ingin menghapus "${contact['name']}" dari daftar kontak darurat?', + + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textSecondary), + + ), + + actions: [ + + TextButton( + + onPressed: () => Navigator.pop(context), + + child: Text('Batal', style: GoogleFonts.inter(color: AppColors.textSecondary)), + + ), + + ElevatedButton( + + style: ElevatedButton.styleFrom( + + backgroundColor: const Color(0xFFD32F2F), + + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + + ), + + onPressed: () { + _deleteContactFromBackend(contact); + Navigator.pop(context); + AppSnackBar.showWarning( + context, + 'Kontak "${contact['name']}" telah dihapus.', + ); + }, + + child: Text( + + 'Hapus', + + style: GoogleFonts.inter(color: Colors.white, fontWeight: FontWeight.w600), + + ), + + ), + + ], + + ); + + }, + + ); + + } + + + + @override + + Widget build(BuildContext context) { + + return Scaffold( + + body: Container( + + width: double.infinity, + + height: double.infinity, + + decoration: const BoxDecoration( + + gradient: LinearGradient( + + colors: [ + + Color(0xFFF6F8FF), + + Color(0xFFEFF2FE), + + Color(0xFFF8F9FE), + + ], + + begin: Alignment.topCenter, + + end: Alignment.bottomCenter, + + ), + + ), + + child: SafeArea( + + child: Column( + + children: [ + + // Header Bar with Back Button Only (Cleaned App Bar) + StaggeredEntrance( + index: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Text( + 'Kontak Darurat Krisis', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ), + + const Divider(height: 1, color: Color(0xFFEBECEF)), + + + + // Scrollable Content + + Expanded( + + child: SingleChildScrollView( + + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + // Info Banner Card + StaggeredEntrance( + index: 1, + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(18), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFFFDCDD), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.shield_outlined, + color: Color(0xFFD32F2F), + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'RENCANA KESELAMATAN KRISIS', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 4), + Text( + 'Kontak ini siap dihubungi secara instan saat sistem EVA mendeteksi indikasi krisis emosional tinggi.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + + // List Header Title (Cleaned header without duplicate button) + StaggeredEntrance( + index: 2, + child: Text( + 'DAFTAR KONTAK (${_contacts.length})', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + ), + const SizedBox(height: 12), + + + + // Contacts List Items + StaggeredEntrance( + index: 3, + child: _isLoading + ? const EmergencyContactsSkeletonLoader() + : _contacts.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40.0), + child: Column( + children: [ + const Icon(Icons.contacts_outlined, size: 48, color: AppColors.textLight), + const SizedBox(height: 12), + Text( + 'Belum ada kontak darurat', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ) + : ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _contacts.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final contact = _contacts[index]; + final bool isPrimary = contact['isPrimary'] == true; + return GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: isPrimary ? const Color(0xFFFFDCDD) : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + isPrimary ? Icons.star_rounded : Icons.person_outline, + color: isPrimary ? const Color(0xFFD32F2F) : AppColors.primary, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + contact['name'] ?? '', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isPrimary) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFFFEBEE), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFFFCDD2)), + ), + child: Text( + 'UTAMA', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w800, + color: const Color(0xFFD32F2F), + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + contact['relationship'] ?? '', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + const Divider(height: 1, color: Color(0xFFF0F1F5)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: InkWell( + onTap: () => _copyToClipboard(contact['phone'] ?? '', 'Nomor Telepon'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + const Icon(Icons.phone_outlined, size: 16, color: AppColors.textSecondary), + const SizedBox(width: 8), + Flexible( + child: Text( + contact['phone'] ?? '', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 10), + IconButton( + icon: const Icon(Icons.edit_outlined, size: 20), + color: AppColors.primary, + onPressed: () => _showContactFormDialog(contactToEdit: contact), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + color: const Color(0xFFD32F2F), + onPressed: () => _confirmDeleteContact(contact), + ), + ], + ), + ], + ), + ); + }, + ), + ), + + const SizedBox(height: 28), + + // Official Emergency & Counseling Hotlines Section + StaggeredEntrance( + index: 4, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFFE4DCFF), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.support_agent, + size: 16, + color: Color(0xFF6C5CE7), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Hotline Resmi 24 Jam', + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: 0.3, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + _buildOfficialHotlineCard( + title: 'Layanan SEJIWA (Kemenkes & BNPB)', + desc: 'Konseling psikologis gratis dari pemerintah RI', + number: '119', + displayNumber: '119 (Ekstensi 8)', + icon: Icons.local_hospital_outlined, + accentColor: const Color(0xFF6C5CE7), + badgeBg: const Color(0xFFF3F0FF), + ), + const SizedBox(height: 10), + _buildOfficialHotlineCard( + title: 'LISA - Sahabat Jiwa', + desc: 'Hotline pencegahan krisis & WhatsApp 24 Jam', + number: '08113855472', + displayNumber: '0811-3855-472', + icon: Icons.phone_in_talk_outlined, + accentColor: const Color(0xFF00CEC9), + badgeBg: const Color(0xFFD7F3FF), + ), + const SizedBox(height: 10), + _buildOfficialHotlineCard( + title: 'Nomor Darurat Nasional Terpadu', + desc: 'Panggilan darurat nasional bebas pulsa (Polisi/Damkar/Medis)', + number: '112', + displayNumber: '112', + icon: Icons.emergency_outlined, + accentColor: const Color(0xFFD32F2F), + badgeBg: const Color(0xFFFFDCDD), + ), + const SizedBox(height: 10), + _buildOfficialHotlineCard( + title: 'Halo Kemenkes', + desc: 'Informasi fasilitas kesehatan & layanan rujukan', + number: '1500567', + displayNumber: '1500-567', + icon: Icons.info_outline, + accentColor: const Color(0xFF0984E3), + badgeBg: const Color(0xFFDFE6E9), + ), + ], + ), + ), + const SizedBox(height: 80), + + ], + + ), + + ), + + ), + + ], + + ), + + ), + + ), + // Single Efficient Floating Action Button (FAB) + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showContactFormDialog(), + backgroundColor: AppColors.primary, + icon: const Icon(Icons.add, color: Colors.white), + label: Text( + 'Tambah Kontak', + style: GoogleFonts.inter(color: Colors.white, fontWeight: FontWeight.w600), + ), + ), + ); + } + + Widget _buildOfficialHotlineCard({ + required String title, + required String desc, + required String number, + required String displayNumber, + required IconData icon, + required Color accentColor, + required Color badgeBg, + }) { + return GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: badgeBg, + shape: BoxShape.circle, + ), + child: Icon(icon, color: accentColor, size: 20), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + desc, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + displayNumber, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w800, + color: accentColor, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 18, color: AppColors.textSecondary), + tooltip: 'Salin nomor', + onPressed: () => _copyToClipboard(number, title), + ), + ], + ), + ); + } +} + diff --git a/apps/eva_mobile/lib/features/emergency/presentation/screens/support_emergency_screen.dart b/apps/eva_mobile/lib/features/emergency/presentation/screens/support_emergency_screen.dart new file mode 100644 index 0000000..7293030 --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/screens/support_emergency_screen.dart @@ -0,0 +1,443 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:eva_mobile/core/routes/app_route_transitions.dart'; +import 'package:eva_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:eva_mobile/features/emergency/presentation/widgets/breathing_exercise_modal.dart'; +import 'package:eva_mobile/features/emergency/presentation/widgets/emergency_hotline_modal.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class SupportEmergencyScreen extends ConsumerStatefulWidget { + const SupportEmergencyScreen({super.key}); + + /// Custom Route with smooth fade & gentle slide-up animation for enter and exit + static Route<void> route() { + return AppRouteTransitions.modalSlideUpRoute<void>( + page: const SupportEmergencyScreen(), + ); + } + + @override + ConsumerState<SupportEmergencyScreen> createState() => _SupportEmergencyScreenState(); +} + +class _SupportEmergencyScreenState extends ConsumerState<SupportEmergencyScreen> + with SingleTickerProviderStateMixin { + late final AnimationController _pulseController; + late final Animation<double> _pulseScale; + + Map<String, dynamic>? _primaryContact; + bool _isLoadingContact = true; + + static const String _dinkesWhatsappUrl = + 'https://api.whatsapp.com/send/?phone=6281380073120&text=halo%20kak%2C%20saya%20ingin%20bercerita%20mengenai...&type=phone_number&app_absent=0'; + + @override + void initState() { + super.initState(); + _initAnimations(); + _fetchPrimaryEmergencyContact(); + } + + void _initAnimations() { + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + )..repeat(reverse: true); + _pulseScale = Tween<double>( + begin: 0.94, + end: 1.06, + ).animate(CurvedAnimation(parent: _pulseController, curve: Curves.easeInOutSine)); + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + Future<void> _fetchPrimaryEmergencyContact() async { + try { + final contacts = await ref.read(authRepositoryProvider).getEmergencyContacts(); + if (contacts.isNotEmpty && mounted) { + final primary = contacts.firstWhere( + (c) => c.isPrimary, + orElse: () => contacts.first, + ); + setState(() { + _primaryContact = { + 'id': primary.id, + 'name': primary.name, + 'relationship': primary.relationship, + 'phone': primary.phone, + 'is_primary': primary.isPrimary, + }; + _isLoadingContact = false; + }); + return; + } + } catch (_) {} + if (mounted) { + setState(() { + _isLoadingContact = false; + }); + } + } + + Future<void> _openDinkesHotline() async { + final uri = Uri.parse(_dinkesWhatsappUrl); + try { + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched) { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } + } catch (_) { + try { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } catch (_) { + if (mounted) { + AppSnackBar.showError(context, 'Tidak dapat membuka WhatsApp Dinkes'); + } + } + } + } + + void _onTapHubungiOrangTerdekat() { + if (_primaryContact != null && _primaryContact!['phone'] != null) { + final phone = _primaryContact!['phone'].toString().replaceAll(RegExp(r'[^\d+]'), ''); + final uri = Uri.parse('tel:$phone'); + launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + Navigator.pushNamed(context, '/emergency_contacts'); + } + } + + void _openBreathingExercise() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => const BreathingExerciseModal(), + ); + } + + void _openHotlineModal() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => const EmergencyHotlineModal(), + ); + } + + void _handleDismiss() { + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final contactSubtitle = _isLoadingContact + ? 'Memuat data kontak...' + : (_primaryContact != null + ? '${_primaryContact!['name']} (${_primaryContact!['relation']})' + : 'Atur nomor kontak keluarga atau teman'); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + _handleDismiss(); + } + }, + child: Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFFFF4F5), + Color(0xFFFFE8EC), + Color(0xFFFFF0F2), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 20.0), + child: Column( + children: [ + const Spacer(flex: 1), + + // Heart Badge Visual with Breathing Pulse Animation & Tap action // Animated Soft Emergency / Pulse Indicator + StaggeredEntrance( + index: 0, + child: Column( + children: [ + GestureDetector( + onTap: _openBreathingExercise, + child: AnimatedBuilder( + animation: _pulseScale, + builder: (context, child) { + return Transform.scale( + scale: _pulseScale.value, + child: child, + ); + }, + child: Container( + width: 90, + height: 90, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFFFE0E3), + boxShadow: [ + BoxShadow( + color: const Color(0xFFE57373).withValues(alpha: 0.25), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: const Icon( + Icons.favorite, + color: Color(0xFFE57373), + size: 40, + ), + ), + ), + ), + const SizedBox(height: 22), + + // Title & Subtitle + Text( + 'Tarik napas dalam-dalam.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 26, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + "EVA menyadari bahwa kamu mungkin membutuhkan dukungan ekstra saat ini. Kamu tidak harus menghadapi ini sendirian.", + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + + // Quick breathing guide trigger button + StaggeredEntrance( + index: 1, + child: GestureDetector( + onTap: _openBreathingExercise, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: const Color(0xFFE57373).withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.air, + size: 16, + color: Color(0xFFE57373), + ), + const SizedBox(width: 8), + Text( + 'Mulai Latihan Napas 4-7-8', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFFE57373), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 24), + + // Action Pill Card 1: WhatsApp Krisis Dinkes DKI + StaggeredEntrance( + index: 2, + child: _buildSupportPillCard( + icon: Icons.support_agent_rounded, + iconBg: const Color(0xFFE8F8F5), + iconColor: const Color(0xFF00B894), + title: 'WhatsApp Krisis Dinkes DKI', + subtitle: 'Pendampingan Konseling Krisis Resmi', + onTap: _openDinkesHotline, + ), + ), + const SizedBox(height: 12), + + // Action Pill Card 2: Hubungi Orang Terdekat + StaggeredEntrance( + index: 3, + child: _buildSupportPillCard( + icon: Icons.perm_contact_calendar_outlined, + iconBg: const Color(0xFFFFDCDD), + iconColor: const Color(0xFFE57373), + title: 'Hubungi Orang Terdekat', + subtitle: contactSubtitle, + onTap: _onTapHubungiOrangTerdekat, + ), + ), + const SizedBox(height: 12), + + // Action Pill Card 3: Hotline Krisis & Bantuan Darurat + StaggeredEntrance( + index: 4, + child: _buildSupportPillCard( + icon: Icons.language, + iconBg: const Color(0xFFD7F3FF), + iconColor: const Color(0xFF00CEC9), + title: 'Hotline Krisis & Bantuan Darurat', + subtitle: 'SEJIWA 119, LISA, 112 Bebas Pulsa', + onTap: _openHotlineModal, + ), + ), + const Spacer(flex: 2), + + // 'Saya merasa lebih baik sekarang' Bottom Button + StaggeredEntrance( + index: 5, + child: GestureDetector( + onTap: _handleDismiss, + child: Container( + width: double.infinity, + height: 54, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: const Color(0xFFE57373).withValues(alpha: 0.4), + width: 1.2, + ), + ), + child: Center( + child: Text( + "Saya merasa lebih baik sekarang", + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildSupportPillCard({ + required IconData icon, + required Color iconBg, + required Color iconColor, + required String title, + String? subtitle, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white, width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: iconBg, + ), + child: Icon(icon, color: iconColor, size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + if (subtitle != null && subtitle.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11.5, + color: AppColors.textSecondary, + fontWeight: FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + const Icon( + Icons.arrow_forward_ios, + size: 15, + color: AppColors.textLight, + ), + const SizedBox(width: 4), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/emergency/presentation/widgets/breathing_exercise_modal.dart b/apps/eva_mobile/lib/features/emergency/presentation/widgets/breathing_exercise_modal.dart new file mode 100644 index 0000000..db15911 --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/widgets/breathing_exercise_modal.dart @@ -0,0 +1,235 @@ +import "dart:async"; +import "package:flutter/material.dart"; +import "package:google_fonts/google_fonts.dart"; + +import "../../../../theme/app_colors.dart"; + +class BreathingExerciseModal extends StatefulWidget { + const BreathingExerciseModal({super.key}); + + @override + State<BreathingExerciseModal> createState() => BreathingExerciseModalState(); +} + +class BreathingExerciseModalState extends State<BreathingExerciseModal> { + Timer? _timer; + int _seconds = 0; + bool _isPlaying = true; + + @override + void initState() { + super.initState(); + _startTimer(); + } + + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return; + setState(() { + _seconds++; + }); + }); + } + + void _togglePlayPause() { + setState(() { + _isPlaying = !_isPlaying; + if (_isPlaying) { + _startTimer(); + } else { + _timer?.cancel(); + } + }); + } + + void _reset() { + setState(() { + _seconds = 0; + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // 4-7-8 Breathing Cycle (Total: 19 seconds per cycle) + final cycle = _seconds % 19; + String phaseText; + String phaseDesc; + Color phaseColor; + double targetScale; + + if (cycle < 4) { + phaseText = 'Tarik Napas'; + phaseDesc = 'Hirup napas perlahan melalui hidung (${4 - cycle}s)'; + phaseColor = const Color(0xFF6C5CE7); + targetScale = 0.85 + (cycle / 4.0) * 0.35; + } else if (cycle < 11) { + final holdLeft = 11 - cycle; + phaseText = 'Tahan Napas'; + phaseDesc = 'Tahan napas di dalam dada ($holdLeft s)'; + phaseColor = const Color(0xFFF59E0B); + targetScale = 1.2; + } else { + final exhaleLeft = 19 - cycle; + phaseText = 'Hembuskan Napas'; + phaseDesc = 'Lepaskan napas perlahan dari mulut ($exhaleLeft s)'; + phaseColor = const Color(0xFF00CEC9); + targetScale = 1.2 - ((cycle - 11) / 8.0) * 0.35; + } + + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Latihan Napas 4-7-8', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'Meredakan panik & menstabilkan detak jantung', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + IconButton( + icon: const Icon(Icons.close, size: 22), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 36), + + // Animated Breathing Circle + SizedBox( + height: 180, + child: Center( + child: AnimatedScale( + scale: targetScale, + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: phaseColor.withValues(alpha: 0.15), + border: Border.all(color: phaseColor, width: 3), + boxShadow: [ + BoxShadow( + color: phaseColor.withValues(alpha: 0.25), + blurRadius: 24, + spreadRadius: 4, + ), + ], + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.air, color: phaseColor, size: 30), + const SizedBox(height: 6), + Text( + phaseText, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: phaseColor, + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + + // Phase Description + Text( + phaseDesc, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 28), + + // Controls + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + onPressed: _reset, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Mulai Ulang'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textSecondary, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: _togglePlayPause, + icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow, size: 18), + label: Text(_isPlaying ? 'Jeda' : 'Lanjutkan'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/emergency/presentation/widgets/contact_form_dialog.dart b/apps/eva_mobile/lib/features/emergency/presentation/widgets/contact_form_dialog.dart new file mode 100644 index 0000000..a2efd6d --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/widgets/contact_form_dialog.dart @@ -0,0 +1,419 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_snack_bar.dart'; + +/// Modal dialog for adding or editing emergency contacts. +class ContactFormDialog extends StatefulWidget { + final Map<String, dynamic>? contactToEdit; + final void Function({ + required String name, + required String relation, + required String phone, + required bool isPrimary, + Map<String, dynamic>? contactToEdit, + }) onSave; + + const ContactFormDialog({ + super.key, + this.contactToEdit, + required this.onSave, + }); + + static Future<void> show({ + required BuildContext context, + Map<String, dynamic>? contactToEdit, + required void Function({ + required String name, + required String relation, + required String phone, + required bool isPrimary, + Map<String, dynamic>? contactToEdit, + }) onSave, + }) { + return showDialog( + context: context, + builder: (context) => ContactFormDialog( + contactToEdit: contactToEdit, + onSave: onSave, + ), + ); + } + + @override + State<ContactFormDialog> createState() => _ContactFormDialogState(); +} + +class _ContactFormDialogState extends State<ContactFormDialog> { + late final TextEditingController _nameController; + late final TextEditingController _relationController; + late final TextEditingController _phoneController; + late bool _isPrimary; + + bool get _isEditing => widget.contactToEdit != null; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController( + text: _isEditing ? (widget.contactToEdit!['name'] ?? '') : '', + ); + _relationController = TextEditingController( + text: _isEditing ? (widget.contactToEdit!['relation'] ?? '') : '', + ); + _phoneController = TextEditingController( + text: _isEditing ? (widget.contactToEdit!['phone'] ?? '') : '', + ); + _isPrimary = _isEditing ? (widget.contactToEdit!['isPrimary'] ?? false) : false; + } + + @override + void dispose() { + _nameController.dispose(); + _relationController.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.white, + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _isEditing + ? AppColors.primary.withValues(alpha: 0.12) + : const Color(0xFFFFDCDD), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + _isEditing + ? Icons.edit_note_rounded + : Icons.person_add_alt_1_rounded, + color: _isEditing + ? AppColors.primary + : const Color(0xFFD32F2F), + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isEditing + ? 'Edit Kontak Darurat' + : 'Tambah Kontak Baru', + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'Orang terpercaya untuk situasi darurat', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon( + Icons.close_rounded, + size: 20, + color: AppColors.textLight, + ), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 18), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 16), + + // Input 1: Nama Lengkap + _buildFormFieldLabel( + icon: Icons.badge_outlined, + label: 'NAMA LENGKAP', + ), + const SizedBox(height: 6), + TextField( + controller: _nameController, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + decoration: _buildInputDecoration( + hint: 'Misal: Ayah, Ibu, Sahabat', + prefixIcon: Icons.person_outline_rounded, + ), + ), + const SizedBox(height: 14), + + // Input 2: Hubungan / Peran + _buildFormFieldLabel( + icon: Icons.group_outlined, + label: 'HUBUNGAN / PERAN', + ), + const SizedBox(height: 6), + TextField( + controller: _relationController, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + decoration: _buildInputDecoration( + hint: 'Misal: Ibu Kandung, Sahabat Kantor', + prefixIcon: Icons.diversity_1_outlined, + ), + ), + const SizedBox(height: 14), + + // Input 3: Nomor Telepon + _buildFormFieldLabel( + icon: Icons.call_outlined, + label: 'NOMOR TELEPON / WHATSAPP', + ), + const SizedBox(height: 6), + TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + decoration: _buildInputDecoration( + hint: '0812-XXXX-XXXX', + prefixIcon: Icons.phone_android_rounded, + ), + ), + const SizedBox(height: 16), + + // Switch: Kontak Utama + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + decoration: BoxDecoration( + color: _isPrimary + ? const Color(0xFFF0FDF4) + : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: _isPrimary + ? const Color(0xFF86EFAC).withValues(alpha: 0.6) + : const Color(0xFFE2E8F0), + width: 1.2, + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: _isPrimary + ? const Color(0xFFD1FAE5) + : const Color(0xFFF1F5F9), + shape: BoxShape.circle, + ), + child: Icon( + Icons.star_rounded, + size: 18, + color: _isPrimary + ? const Color(0xFF047857) + : AppColors.textLight, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Jadikan Kontak Utama', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + 'Prioritas panggilan darurat pertama', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Switch.adaptive( + value: _isPrimary, + activeThumbColor: const Color(0xFF10B981), + activeTrackColor: const Color(0xFFD1FAE5), + onChanged: (val) { + setState(() { + _isPrimary = val; + }); + }, + ), + ], + ), + ), + const SizedBox(height: 20), + + // Action Buttons + Row( + children: [ + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textSecondary, + side: const BorderSide(color: Color(0xFFE2E8F0)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 13), + ), + onPressed: () => Navigator.pop(context), + child: Text( + 'Batal', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 13), + ), + icon: Icon( + _isEditing + ? Icons.check_rounded + : Icons.add_rounded, + size: 18, + ), + label: Text( + _isEditing ? 'Simpan Perubahan' : 'Tambah Kontak', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + onPressed: () { + final name = _nameController.text.trim(); + final relation = _relationController.text.trim(); + final phone = _phoneController.text.trim(); + + if (name.isEmpty || phone.isEmpty) { + AppSnackBar.showWarning( + context, + 'Nama dan nomor telepon wajib diisi', + duration: const Duration(seconds: 2), + ); + return; + } + + Navigator.pop(context); + widget.onSave( + name: name, + relation: relation, + phone: phone, + isPrimary: _isPrimary, + contactToEdit: _isEditing ? widget.contactToEdit : null, + ); + }, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildFormFieldLabel({required IconData icon, required String label}) { + return Row( + children: [ + Icon(icon, size: 13, color: AppColors.textLight), + const SizedBox(width: 5), + Text( + label, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + ], + ); + } + + InputDecoration _buildInputDecoration({ + required String hint, + required IconData prefixIcon, + }) { + return InputDecoration( + hintText: hint, + hintStyle: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textLight, + ), + prefixIcon: Icon(prefixIcon, size: 18, color: AppColors.textSecondary), + filled: true, + fillColor: const Color(0xFFF8FAFC), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_contacts_skeleton_loader.dart b/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_contacts_skeleton_loader.dart new file mode 100644 index 0000000..69f9fef --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_contacts_skeleton_loader.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../../../../widgets/glass_card.dart'; +import '../../../../widgets/skeleton_shimmer.dart'; + +/// Shimmer skeleton loader for Emergency Contacts list. +class EmergencyContactsSkeletonLoader extends StatelessWidget { + const EmergencyContactsSkeletonLoader({super.key}); + + @override + Widget build(BuildContext context) { + return SkeletonShimmerHost( + child: Column( + children: List.generate( + 3, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SkeletonBox(width: 44, height: 44, borderRadius: 14), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 140, height: 16), + SizedBox(height: 6), + SkeletonLine(width: 90, height: 12), + ], + ), + ), + const SkeletonBox(width: 60, height: 22, borderRadius: 6), + ], + ), + const SizedBox(height: 14), + Row( + children: const [ + SkeletonBox(width: 130, height: 28, borderRadius: 8), + Spacer(), + SkeletonCircle(size: 32), + SizedBox(width: 8), + SkeletonCircle(size: 32), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_hotline_modal.dart b/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_hotline_modal.dart new file mode 100644 index 0000000..50c0b81 --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_hotline_modal.dart @@ -0,0 +1,207 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:google_fonts/google_fonts.dart"; + +import "../../../../theme/app_colors.dart"; +import "../../../../widgets/app_snack_bar.dart"; + +class EmergencyHotlineModal extends StatelessWidget { + const EmergencyHotlineModal({super.key}); + + void _copy(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + AppSnackBar.showSuccess( + context, + '$label ($text) disalin ke clipboard', + duration: const Duration(seconds: 2), + ); + } + + @override + Widget build(BuildContext context) { + final hotlines = [ + { + 'title': 'Layanan SEJIWA (Kemenkes)', + 'desc': 'Hotline kesehatan mental & pencegahan krisis', + 'number': '119', + 'display': '119 (Ext. 8)', + 'icon': Icons.health_and_safety_outlined, + }, + { + 'title': 'LISA - Sahabat Jiwa', + 'desc': 'Bantuan darurat kesehatan mental 24 Jam', + 'number': '08113855472', + 'display': '0811-3855-472', + 'icon': Icons.phone_in_talk_outlined, + }, + { + 'title': 'Nomor Darurat Nasional', + 'desc': 'Panggilan darurat terpadu bebas pulsa', + 'number': '112', + 'display': '112', + 'icon': Icons.emergency_outlined, + }, + { + 'title': 'Ambulans Kedaruratan Medis', + 'desc': 'Layanan tanggap darurat medis cepat', + 'number': '118', + 'display': '118 / 119', + 'icon': Icons.local_hospital_outlined, + }, + { + 'title': 'Kepolisian Republik Indonesia', + 'desc': 'Kedaruratan keamanan & keselamatan jiwa', + 'number': '110', + 'display': '110', + 'icon': Icons.local_police_outlined, + }, + { + 'title': 'Halo Kemenkes', + 'desc': 'Informasi fasilitas layanan kesehatan resmi', + 'number': '1500567', + 'display': '1500-567', + 'icon': Icons.info_outline, + }, + ]; + + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 28), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 18), + + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Hotline Krisis & Darurat', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 22), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Tekan ikon salin untuk menyalin nomor langsung ke dialer telepon.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 16), + + // List + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: hotlines.length, + separatorBuilder: (context, index) => const SizedBox(height: 10), + itemBuilder: (ctx, i) { + final h = hotlines[i]; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFD7F3FF), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + h['icon'] as IconData, + color: const Color(0xFF00CEC9), + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + h['title'] as String, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + h['desc'] as String, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + h['display'] as String, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w800, + color: const Color(0xFF00838F), + ), + ), + ], + ), + ), + IconButton( + icon: const Icon( + Icons.copy, + size: 18, + color: AppColors.textSecondary, + ), + tooltip: 'Salin nomor', + onPressed: () => _copy( + context, + h['number'] as String, + h['title'] as String, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} + + diff --git a/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart b/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart new file mode 100644 index 0000000..72cbda2 --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart @@ -0,0 +1,688 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/features/auth/domain/entities/user_entity.dart'; +import 'package:eva_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:eva_mobile/features/chat/domain/entities/conversation_entity.dart'; +import 'package:eva_mobile/features/chat/presentation/providers/chat_provider.dart'; +import 'package:eva_mobile/features/dass/domain/entities/dass_assessment_entity.dart'; +import 'package:eva_mobile/features/dass/presentation/providers/dass_provider.dart'; +import 'package:eva_mobile/features/diary/domain/entities/diary_entry_entity.dart'; +import 'package:eva_mobile/features/diary/presentation/providers/diary_provider.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/home_activity_modal.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/home_daily_progress_card.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/home_eva_insight_card.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/home_quick_actions.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/home_recommendations_section.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/profile_dropdown_modal.dart'; +import 'package:eva_mobile/features/recommendations/presentation/providers/recommendation_provider.dart'; +import 'package:eva_mobile/providers/daily_progress_provider.dart'; +import 'package:eva_mobile/services/daily_progress_local_service.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class HomeScreen extends ConsumerStatefulWidget { + final ValueChanged<int>? onNavigateTab; + + const HomeScreen({ + super.key, + this.onNavigateTab, + }); + + @override + ConsumerState<HomeScreen> createState() => HomeScreenState(); +} + +class HomeScreenState extends ConsumerState<HomeScreen> with WidgetsBindingObserver { + String _userName = 'Sahabat EVA'; + String _userEmail = 'samsul@gmail.com'; + int _todayConversationsCount = 0; + + String get _userInitials { + final parts = _userName.trim().split(RegExp(r'\s+')); + if (parts.length >= 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } else if (parts.isNotEmpty && parts[0].isNotEmpty) { + return parts[0].substring(0, parts[0].length >= 2 ? 2 : 1).toUpperCase(); + } + return 'SA'; + } + bool _hasTodayDiary = false; + String _aiInsight = + 'Setiap perasaan yang kamu alami hari ini adalah valid. Luangkan waktu sejenak untuk menarik napas dalam dan menyayangi dirimu sendiri.'; + + DailyProgressData _localProgress = const DailyProgressData(date: ''); + bool _hasTodayDass = false; + bool _dassVerifiedByUser = false; + String _dassStatus = ''; + int _dassStressScore = 0; + int _dassAnxietyScore = 0; + int _dassDepressionScore = 0; + String _targetCondition = 'general'; + String _conditionLabel = 'Fokus: Relaksasi & Perawatan Diri'; + String _conditionReason = 'Menjaga ritme emosi dan ketenangan pikiran harian'; + String _severityLevel = 'Normal'; + bool _isCrisis = false; + String _crisisHotline = 'Kemenkes 119 ext 8 / LISA 0811-3855-472'; + String _crisisGuidance = + 'Keselamatan dan ketenanganmu adalah prioritas paling berharga. Silakan hubungi bantuan darurat bila beban terasa terlalu berat.'; + Map<String, dynamic>? _todayActivity; + + static final List<Map<String, dynamic>> _defaultRecommendations = [ + { + 'id': '1', + 'title': 'Latihan Pernapasan 4-7-8', + 'category': 'Mindfulness', + 'targetCondition': 'stress', + 'duration': '3 menit', + 'difficulty': 'Pemula', + 'description': + 'Tarik napas dalam 4 detik, tahan 7 detik, lalu hembuskan perlahan 8 detik untuk meredakan ketegangan sistem saraf.', + 'instructions': [ + 'Duduk dengan nyaman dan punggung tegak.', + 'Tarik napas perlahan melalui hidung selama 4 detik.', + 'Tahan napasmu selama 7 detik.', + 'Hembuskan napas secara perlahan melalui mulut selama 8 detik.', + 'Ulangi siklus ini sebanyak 4 kali putaran.', + ], + 'rationale': + 'Mengaktifkan respons relaksasi parasimpatik untuk menurunkan detak jantung dan meredakan ketegangan.', + 'isCompleted': false, + }, + { + 'id': '2', + 'title': 'Teknik Grounding 5-4-3-2-1', + 'category': 'CBT', + 'targetCondition': 'anxiety', + 'duration': '5 menit', + 'difficulty': 'Pemula', + 'description': + 'Sadarilah 5 hal yang kamu lihat, 4 hal yang kamu sentuh, 3 suara di sekitarmu, 2 aroma, dan 1 rasa untuk kembali ke saat ini.', + 'instructions': [ + 'Sebutkan 5 hal yang bisa kamu lihat di sekitarmu.', + 'Sentuh dan rasakan 4 benda yang ada di dekatmu.', + 'Dengarkan secara saksama 3 suara berbeda di ruangan ini.', + 'Identifikasi 2 aroma yang tercium olehmu saat ini.', + 'Fokuskan lidahmu pada 1 rasa yang masih tertinggal di mulutmu.', + ], + 'rationale': + 'Mengalihkan fokus otak dari lingkaran kecemasan ke persepsi sensorik nyata saat ini.', + 'isCompleted': false, + }, + { + 'id': '3', + 'title': 'Catatan Syukur Harian', + 'category': 'Refleksi', + 'targetCondition': 'depression', + 'duration': '3 menit', + 'difficulty': 'Pemula', + 'description': + 'Tuliskan satu hal sederhana yang memberimu kehangatan atau kenyamanan hari ini.', + 'instructions': [ + 'Tarik napas dalam sejenak dan pejamkan matamu perlahan.', + 'Ingat satu momen kecil yang membuatmu merasa hangat atau tersenyum hari ini.', + 'Tuliskan secara spesifik apa momen itu dan mengapa itu berarti bagimu.', + ], + 'rationale': + 'Melatih otak mengenali afek positif dan memutus kecenderungan bias negatif.', + 'isCompleted': false, + }, + ]; + + late List<Map<String, dynamic>> _recommendations; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _recommendations = List<Map<String, dynamic>>.from( + _defaultRecommendations.map((item) => Map<String, dynamic>.from(item)), + ); + _loadHomeScreenData(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + refresh(); + } + } + + void refresh() { + _loadHomeScreenData(); + ref.read(dailyProgressProvider.notifier).refresh(); + } + + Future<void> _loadHomeScreenData() async { + // 1. Load cached user info first for instant display + final cachedName = await AppConfig.getUserName(); + final cachedEmail = await AppConfig.getUserEmail(); + if (cachedName != null && cachedName.isNotEmpty && mounted) { + setState(() => _userName = cachedName); + } + if (cachedEmail != null && cachedEmail.isNotEmpty && mounted) { + _userEmail = cachedEmail; + } + + // 2. Load today's local self-care progress (auto-reset if new day) + final localData = await DailyProgressLocalService.loadTodayProgress(); + if (mounted) { + setState(() => _localProgress = localData); + } + + try { + final results = await Future.wait([ + // 0: Profile + ref.read(authRepositoryProvider).getCurrentUser().catchError((_) => null), + // 1: Today conversations + ref.read(chatRepositoryProvider).getConversations().catchError((_) => <ConversationEntity>[]), + // 2: Today diary & emotions + ref.read(diaryRepositoryProvider).getTodayDiary().catchError((_) => null), + // 3: Tailored recommendation for today + ref.read(recommendationRepositoryProvider).getTodayRecommendation().catchError((_) => <String, dynamic>{}), + // 4: Today DASS-21 assessment + ref.read(dassRepositoryProvider).getTodayAssessment().then<DASSAssessmentEntity?>((a) => a).catchError((_) => null), + ]); + + if (!mounted) return; + + // Handle Profile + final user = results[0] as UserEntity?; + if (user != null) { + _userName = user.name; + if (user.email.isNotEmpty) { + _userEmail = user.email; + } + } + + // Handle Today Conversations + final conversations = results[1] as List<ConversationEntity>? ?? []; + _todayConversationsCount = conversations.length; + if (_todayConversationsCount > 0) { + _localProgress = await DailyProgressLocalService.recordConversationCheckin( + hasConversation: true, + ); + } + + // Handle Today Diary + final diary = results[2] as DiaryEntryEntity?; + if (diary != null) { + _hasTodayDiary = true; + _localProgress = await DailyProgressLocalService.recordDiaryCheckin( + hasDiary: true, + ); + if (diary.aiInsight.isNotEmpty) { + _aiInsight = diary.aiInsight; + } + } + + // Handle Today Tailored Recommendation + final recData = results[3] as Map<String, dynamic>? ?? {}; + if (recData['todayActivity'] != null) { + _targetCondition = recData['targetCondition']?.toString() ?? 'general'; + _conditionLabel = + recData['conditionLabel']?.toString() ?? 'Fokus: Relaksasi & Perawatan Diri'; + _conditionReason = recData['conditionReason']?.toString() ?? ''; + _severityLevel = recData['severityLevel']?.toString() ?? 'Normal'; + _isCrisis = recData['isCrisis'] == true; + if (recData['crisisHotline'] != null) { + _crisisHotline = recData['crisisHotline'].toString(); + } + if (recData['crisisGuidance'] != null) { + _crisisGuidance = recData['crisisGuidance'].toString(); + } + + final act = recData['todayActivity'] as Map<String, dynamic>; + final actId = act['id']?.toString() ?? ''; + final bool isActDone = act['isCompleted'] == true || + _localProgress.isActivityCompleted(actId); + + final actMap = { + 'id': actId, + 'title': act['title']?.toString() ?? 'Latihan Relaksasi', + 'category': act['category']?.toString() ?? 'Self-Care', + 'targetCondition': + act['targetCondition']?.toString() ?? _targetCondition, + 'duration': act['duration']?.toString() ?? '5 menit', + 'difficulty': act['difficulty']?.toString() ?? 'Pemula', + 'description': act['description']?.toString() ?? '', + 'instructions': act['instructions'] is List + ? List<String>.from( + act['instructions'].map((e) => e.toString())) + : <String>[], + 'rationale': act['rationale']?.toString() ?? '', + 'iconName': act['iconName']?.toString() ?? 'spa', + 'isCompleted': isActDone, + }; + + _todayActivity = actMap; + + final List<Map<String, dynamic>> combined = [actMap]; + if (recData['alternatives'] is List) { + for (var item in recData['alternatives']) { + final altId = item['id']?.toString() ?? ''; + combined.add({ + 'id': altId, + 'title': item['title']?.toString() ?? 'Rekomendasi', + 'category': item['category']?.toString() ?? 'Self-Care', + 'targetCondition': + item['targetCondition']?.toString() ?? _targetCondition, + 'duration': item['duration']?.toString() ?? '5 menit', + 'difficulty': item['difficulty']?.toString() ?? 'Pemula', + 'description': item['description']?.toString() ?? '', + 'instructions': <String>[], + 'rationale': '', + 'isCompleted': item['isCompleted'] == true || + _localProgress.isActivityCompleted(altId), + }); + } + } + _recommendations = combined; + + if (isActDone && !_localProgress.isActivityCompleted(actId)) { + _localProgress = + await DailyProgressLocalService.recordActivityCompletion( + actId, + true, + ); + } + } + + // Handle Today DASS-21 Assessment + final dass = results[4] as DASSAssessmentEntity?; + if (dass != null) { + _hasTodayDass = dass.id?.isNotEmpty == true; + _dassVerifiedByUser = dass.verifiedByUser; + _dassStatus = dass.status; + _dassStressScore = dass.stressScore; + _dassAnxietyScore = dass.anxietyScore; + _dassDepressionScore = dass.depressionScore; + } + } catch (_) {} + + if (mounted) setState(() {}); + } + + Future<void> _toggleRecommendationComplete(String id) async { + bool currentStatus = false; + final idx = _recommendations.indexWhere((r) => r['id'] == id); + if (idx != -1) { + currentStatus = _recommendations[idx]['isCompleted'] == true; + setState(() { + _recommendations[idx]['isCompleted'] = !currentStatus; + }); + } + + if (_todayActivity != null && _todayActivity!['id'] == id) { + currentStatus = _todayActivity!['isCompleted'] == true; + setState(() { + _todayActivity!['isCompleted'] = !currentStatus; + }); + } + + final newStatus = !currentStatus; + + final updatedProgress = + await DailyProgressLocalService.recordActivityCompletion( + id, + newStatus, + ); + if (mounted) { + setState(() { + _localProgress = updatedProgress; + }); + } + + if (newStatus) { + try { + await ref.read(recommendationRepositoryProvider).markAsCompleted(id); + } catch (_) {} + } + } + + int get _completedRoutinesCount { + int count = 0; + if (_localProgress.hasConversation || _todayConversationsCount > 0) count++; + if (_localProgress.hasDiary || _hasTodayDiary) count++; + final bool activityDone = _localProgress.completedActivityIds.isNotEmpty || + (_todayActivity != null && _todayActivity!['isCompleted'] == true) || + _recommendations.any((r) => r['isCompleted'] == true); + if (activityDone) count++; + return count; + } + + void _showProfileDropdown(BuildContext context) { + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'ProfileDropdown', + barrierColor: Colors.black.withValues(alpha: 0.3), + transitionDuration: const Duration(milliseconds: 220), + pageBuilder: (dialogContext, anim1, anim2) { + return SafeArea( + child: Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(top: 50.0, right: 20.0), + child: Material( + color: Colors.transparent, + child: ProfileDropdownModal( + userName: _userName, + userEmail: _userEmail, + onProfileTap: () { + Navigator.pop(dialogContext); + Navigator.pushNamed(context, '/user_profile').then((_) { + if (mounted) _loadHomeScreenData(); + }); + }, + onEmergencyContactsTap: () { + Navigator.pop(dialogContext); + Navigator.pushNamed(context, '/emergency_contacts'); + }, + onSettingsTap: () { + Navigator.pop(dialogContext); + Navigator.pushNamed(context, '/settings'); + }, + onCoinTap: () { + Navigator.pop(dialogContext); + Navigator.pushNamed(context, '/coin_purchase'); + }, + onLogoutTap: () async { + Navigator.pop(dialogContext); + await AppConfig.clearToken(); + if (context.mounted) { + Navigator.pushNamedAndRemoveUntil(context, '/login', (route) => false); + } + }, + ), + ), + ), + ), + ); + }, + transitionBuilder: (dialogContext, anim1, anim2, child) { + final curved = CurvedAnimation(parent: anim1, curve: Curves.easeOutBack); + return ScaleTransition( + alignment: Alignment.topRight, + scale: Tween<double>(begin: 0.85, end: 1.0).animate(curved), + child: FadeTransition( + opacity: anim1, + child: child, + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final liveProgress = ref.watch(dailyProgressProvider); + _localProgress = liveProgress; + + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: _loadHomeScreenData, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20.0, 16.0, 20.0, 110.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // User Greeting Header with Space Between & Profile Photo + StaggeredEntrance( + index: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Halo, $_userName! 👋', + style: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Bagaimana perasaanmu hari ini?', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () => _showProfileDropdown(context), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [Color(0xFF8B93FF), Color(0xFF5358CB)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.25), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + border: Border.all(color: Colors.white, width: 2), + ), + child: Center( + child: Text( + _userInitials, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 18), + + // Empathetic AI Psychologist Insight Card + StaggeredEntrance( + index: 2, + child: HomeEvaInsightCard(insightText: _aiInsight), + ), + const SizedBox(height: 20), + + // Quick Navigation Shortcuts + StaggeredEntrance( + index: 3, + child: HomeQuickActions( + onTapChat: () async { + await Navigator.pushNamed(context, '/chat'); + refresh(); + }, + onTapDiary: () async { + if (widget.onNavigateTab != null) { + widget.onNavigateTab!(1); + } else { + await Navigator.pushNamed(context, '/diary'); + refresh(); + } + }, + onTapDass: () async { + await Navigator.pushNamed(context, '/dass_assessment'); + refresh(); + }, + dassSubtitle: _dassVerifiedByUser + ? 'Stres: $_dassStressScore • Cemas: $_dassAnxietyScore • Depresi: $_dassDepressionScore' + : (_hasTodayDass && _dassStatus == 'auto_extracted' + ? 'Tersintesis AI • Ketuk untuk meninjau' + : 'Evaluasi & koreksi 21 butir emosi'), + showDassNotificationDot: _hasTodayDass && + _dassStatus == 'auto_extracted' && + !_dassVerifiedByUser, + ), + ), + const SizedBox(height: 24), + + // Evidence-Based Daily Progress Section (3 Pillars) + StaggeredEntrance( + index: 4, + child: HomeDailyProgressCard( + completedCount: _completedRoutinesCount, + isConversationDone: + _localProgress.hasConversation || _todayConversationsCount > 0, + isDiaryDone: _localProgress.hasDiary || _hasTodayDiary, + isExerciseDone: _localProgress.completedActivityIds.isNotEmpty || + (_todayActivity != null && + _todayActivity!['isCompleted'] == true) || + _recommendations.any((r) => r['isCompleted'] == true), + todayConversationsCount: _todayConversationsCount, + onTapConversation: () async { + await Navigator.pushNamed(context, '/chat'); + refresh(); + }, + onTapDiary: () async { + if (widget.onNavigateTab != null) { + widget.onNavigateTab!(1); + } else { + await Navigator.pushNamed(context, '/diary'); + refresh(); + } + }, + onTapExercise: () async { + await Navigator.pushNamed(context, '/recommendation'); + refresh(); + }, + ), + ), + const SizedBox(height: 24), + + // Latest Clinically-Informed Recommendation + StaggeredEntrance( + index: 5, + child: _buildLatestRecommendationSection(), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildLatestRecommendationSection() { + final Map<String, dynamic> activeRec = (_todayActivity != null && + _todayActivity!["isCompleted"] != true) + ? _todayActivity! + : (_recommendations.isNotEmpty + ? _recommendations.firstWhere( + (r) => r["isCompleted"] != true, + orElse: () => _todayActivity ?? _recommendations.first, + ) + : _defaultRecommendations.first); + + final id = activeRec["id"]?.toString() ?? "1"; + final isDone = activeRec["isCompleted"] == true || + _localProgress.isActivityCompleted(id); + + return HomeRecommendationsSection( + activeRec: activeRec, + conditionLabel: _conditionLabel, + severityLevel: _severityLevel, + isCrisis: _isCrisis, + crisisGuidance: _crisisGuidance, + crisisHotline: _crisisHotline, + isDone: isDone, + onSeeAll: () async { + await Navigator.pushNamed(context, "/recommendation"); + _loadHomeScreenData(); + }, + onSupportTap: () { + Navigator.pushNamed(context, "/support"); + }, + onOpenDetail: () => _showActivityDetailModal(context, activeRec), + onToggleComplete: (toggleId) => _toggleRecommendationComplete(toggleId), + ); + } + + void _showActivityDetailModal( + BuildContext context, Map<String, dynamic> activity) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + final id = activity['id']?.toString() ?? ''; + final isDone = (_todayActivity != null && _todayActivity!['id'] == id) + ? (_todayActivity!['isCompleted'] == true) + : _localProgress.isActivityCompleted(id); + + return HomeActivityModalContent( + activity: activity, + conditionReason: _conditionReason, + isCompleted: isDone, + onComplete: () async { + if (isDone) { + AppSnackBar.showPrimary( + context, + 'Kamu sudah menyelesaikan latihan ini hari ini! Kerja bagus, istirahatlah sejenak dan lanjutkan kembali besok 🌿', + icon: Icons.info_outline, + ); + Navigator.pop(ctx); + return; + } + await _toggleRecommendationComplete(id); + if (context.mounted) { + AppSnackBar.showSuccess( + context, + 'Bagus sekali! Satu langkah kecil untuk ketenanganmu telah selesai.', + icon: Icons.check_circle, + ); + Navigator.pop(ctx); + } + }, + ); + }, + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/screens/main_shell_screen.dart b/apps/eva_mobile/lib/features/home/presentation/screens/main_shell_screen.dart new file mode 100644 index 0000000..95199f3 --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/screens/main_shell_screen.dart @@ -0,0 +1,521 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/floating_nav_bar.dart'; +import 'package:eva_mobile/features/diary/presentation/screens/ai_diary_screen.dart'; +import 'home_screen.dart'; +import 'package:eva_mobile/features/monitoring/presentation/screens/monitoring_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/care_screen.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/presentation/providers/tutorial_provider.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; + +/// Lightweight wrapper ensuring each tab maintains its state and scroll position across switches. +class _KeepAliveWrapper extends StatefulWidget { + final Widget child; + + const _KeepAliveWrapper({required this.child}); + + @override + State<_KeepAliveWrapper> createState() => _KeepAliveWrapperState(); +} + +class _KeepAliveWrapperState extends State<_KeepAliveWrapper> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} + +class MainShellScreen extends ConsumerStatefulWidget { + final int initialIndex; + + const MainShellScreen({ + super.key, + this.initialIndex = 0, + }); + + @override + ConsumerState<MainShellScreen> createState() => _MainShellScreenState(); +} + +class _MainShellScreenState extends ConsumerState<MainShellScreen> { + late int _currentIndex; + bool _isNavBarVisible = true; + bool _isPageAnimating = false; + late final PageController _pageController; + + final GlobalKey<HomeScreenState> _homeKey = GlobalKey<HomeScreenState>(); + final GlobalKey<AiDiaryScreenState> _diaryKey = GlobalKey<AiDiaryScreenState>(); + final GlobalKey<MonitoringScreenState> _monitoringKey = GlobalKey<MonitoringScreenState>(); + final GlobalKey<CareScreenState> _careKey = GlobalKey<CareScreenState>(); + + final GlobalKey _centerButtonKey = GlobalKey(debugLabel: 'centerButton'); + final GlobalKey _firstJournalCardKey = GlobalKey(debugLabel: 'firstJournalCard'); + final GlobalKey _monitoringDassCardKey = GlobalKey(debugLabel: 'monitoringDassCard'); + final GlobalKey _firstPsychologistCardKey = GlobalKey(debugLabel: 'firstPsychologistCard'); + final GlobalKey _journalTabKey = GlobalKey(debugLabel: 'journalTab'); + final GlobalKey _trenTabKey = GlobalKey(debugLabel: 'trenTab'); + final GlobalKey _careTabKey = GlobalKey(debugLabel: 'careTab'); + + late final List<Widget> _pages; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex.clamp(0, 3); + _pageController = PageController(initialPage: _currentIndex); + _pages = [ + _KeepAliveWrapper( + child: HomeScreen( + key: _homeKey, + onNavigateTab: _onTabTapped, + ), + ), + _KeepAliveWrapper( + child: AiDiaryScreen( + key: _diaryKey, + firstCardKey: _firstJournalCardKey, + ), + ), + _KeepAliveWrapper( + child: MonitoringScreen( + key: _monitoringKey, + dassCardKey: _monitoringDassCardKey, + ), + ), + _KeepAliveWrapper( + child: CareScreen( + key: _careKey, + firstPsychologistCardKey: _firstPsychologistCardKey, + ), + ), + ]; + + if (widget.initialIndex == 0) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(tutorialNotifierProvider.notifier).ensureActiveOnLaunch(); + }); + } else if (widget.initialIndex == 1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final tutorialState = ref.read(tutorialNotifierProvider); + if (!tutorialState.isCompleted && + tutorialState.currentStep == TutorialStep.journal) { + ref + .read(tutorialNotifierProvider.notifier) + .resumeAtStep(TutorialStep.journal); + } + }); + } + } + + GlobalKey? _getTargetKeyForStep(TutorialStep step) { + switch (step) { + case TutorialStep.voiceSession: + return _centerButtonKey; + case TutorialStep.voiceCoins: + case TutorialStep.voiceStart: + case TutorialStep.voiceCompleted: + case TutorialStep.dassDetail: + case TutorialStep.dassItem: + return null; + case TutorialStep.journal: + return _firstJournalCardKey; + case TutorialStep.analytics: + return _monitoringDassCardKey; + case TutorialStep.care: + return _firstPsychologistCardKey; + } + } + + void _handleCenterButtonTap() { + final tutorialState = ref.read(tutorialNotifierProvider); + if (tutorialState.isActive && + tutorialState.currentStep == TutorialStep.voiceSession) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceSession); + } + Navigator.pushNamed(context, '/chat'); + } + + void _onTabTapped(int index) { + if (index == _currentIndex) { + // Tapping same tab = refresh + _triggerRefresh(index); + return; + } + + setState(() { + _currentIndex = index; + _isNavBarVisible = true; + _isPageAnimating = true; + }); + + _pageController + .animateToPage( + index, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + ) + .then((_) { + if (mounted) { + setState(() { + _isPageAnimating = false; + }); + _triggerRefresh(index); + } + }); + } + + void _triggerRefresh(int index) { + switch (index) { + case 0: + _homeKey.currentState?.refresh(); + break; + case 1: + _diaryKey.currentState?.refresh(); + break; + case 2: + _monitoringKey.currentState?.refresh(); + break; + case 3: + break; + default: + break; + } + } + + Future<bool> _showExitConfirmationDialog() async { + final result = await showDialog<bool>( + context: context, + builder: (BuildContext context) { + return Dialog( + backgroundColor: Colors.white, + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(22, 24, 22, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Empathetic Icon Circle + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.nightlight_round, + color: AppColors.primary, + size: 28, + ), + ), + const SizedBox(height: 18), + + // Title + Text( + 'Keluar dari EVA?', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + + // Friendly, Reassuring Message + Text( + 'Apakah kamu yakin ingin menutup aplikasi? Seluruh sesi dialog, catatan refleksi, dan progres perawatan dirimu hari ini telah tersimpan dengan aman.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 14), + + // Warm Reassurance Card + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + const Text('🌿', style: TextStyle(fontSize: 16)), + const SizedBox(width: 8), + Expanded( + child: Text( + 'EVA selalu siap menemanimu kapan pun kamu ingin kembali bercerita.', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: const Color(0xFF475569), + height: 1.35, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 22), + + // Action Buttons + Row( + children: [ + // Keluar Button + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFE11D48), + side: const BorderSide(color: Color(0xFFFECDD3)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 13), + ), + onPressed: () => Navigator.pop(context, true), + child: Text( + 'Keluar', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: 12), + // Tetap di Sini Button + Expanded( + flex: 2, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 13), + ), + onPressed: () => Navigator.pop(context, false), + child: Text( + 'Tetap di Sini', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + return result ?? false; + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tutorialState = ref.watch(tutorialNotifierProvider); + + ref.listen<TutorialState>(tutorialNotifierProvider, (previous, next) { + if (next.isActive) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (next.currentStep == TutorialStep.journal && _currentIndex != 1) { + _onTabTapped(1); + } else if (next.currentStep == TutorialStep.analytics && _currentIndex != 2) { + _onTabTapped(2); + } else if (next.currentStep == TutorialStep.care && _currentIndex != 3) { + _onTabTapped(3); + } + }); + } + }); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + if (_currentIndex != 0) { + _onTabTapped(0); + return; + } + final shouldExit = await _showExitConfirmationDialog(); + if (shouldExit) { + SystemNavigator.pop(); + } + }, + child: Scaffold( + body: Stack( + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: NotificationListener<UserScrollNotification>( + onNotification: (notification) { + if (notification.direction == ScrollDirection.reverse && + _isNavBarVisible && + !tutorialState.isActive) { + setState(() { + _isNavBarVisible = false; + }); + } else if (notification.direction == ScrollDirection.forward && + !_isNavBarVisible) { + setState(() { + _isNavBarVisible = true; + }); + } + return true; + }, + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: _pages, + ), + ), + ), + ), + + // Animated Floating Navigation Bar (Slides down when scrolling down, slides up on scroll up) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: AnimatedSlide( + offset: _isNavBarVisible ? Offset.zero : const Offset(0, 2.0), + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: FloatingNavBar( + currentIndex: _currentIndex, + onTap: _onTabTapped, + onCenterTap: _handleCenterButtonTap, + centerButtonKey: _centerButtonKey, + journalTabKey: _journalTabKey, + trenTabKey: _trenTabKey, + careTabKey: _careTabKey, + ), + ), + ), + ), + ), + + // Branded Guidance Card + Interactive Spotlight Overlay + if (tutorialState.isActive && + tutorialState.currentStep != null && + !_isPageAnimating && + _getTargetKeyForStep(tutorialState.currentStep!) != null) + InteractiveSpotlightOverlay( + key: ValueKey(tutorialState.currentStep), + targetKey: _getTargetKeyForStep(tutorialState.currentStep!), + secondaryTargetKey: tutorialState.currentStep == TutorialStep.care + ? _careTabKey + : null, + content: TutorialContent.stepContents[tutorialState.currentStep!]!, + onSkip: () => + ref.read(tutorialNotifierProvider.notifier).skipTutorial(), + onNext: () { + final current = tutorialState.currentStep; + if (current == TutorialStep.voiceSession) { + _handleCenterButtonTap(); + return; + } + if (current == TutorialStep.journal) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.journal); + return; + } + if (current == TutorialStep.analytics) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.analytics); + Navigator.pushNamed(context, '/dass_assessment').then((_) { + final tut = ref.read(tutorialNotifierProvider); + if (!tut.isCompleted && tut.currentStep == TutorialStep.care) { + _onTabTapped(3); + } + }); + return; + } + if (current == TutorialStep.care) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.care); + return; + } + ref.read(tutorialNotifierProvider.notifier).nextStep(); + }, + onTargetTap: () { + final current = tutorialState.currentStep; + if (current == TutorialStep.voiceSession) { + _handleCenterButtonTap(); + return; + } + if (current == TutorialStep.journal) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.journal); + return; + } + if (current == TutorialStep.analytics) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.analytics); + Navigator.pushNamed(context, '/dass_assessment').then((_) { + final tut = ref.read(tutorialNotifierProvider); + if (!tut.isCompleted && tut.currentStep == TutorialStep.care) { + _onTabTapped(3); + } + }); + return; + } + if (current == TutorialStep.care) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.care); + return; + } + ref.read(tutorialNotifierProvider.notifier).nextStep(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_activity_modal.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_activity_modal.dart new file mode 100644 index 0000000..59c8e31 --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_activity_modal.dart @@ -0,0 +1,445 @@ +import "dart:async"; +import "package:flutter/material.dart"; +import "package:google_fonts/google_fonts.dart"; + +import "../../../../theme/app_colors.dart"; + +/// Modal bottom sheet content for home screen with rationale, steps, and breath pacer +class HomeActivityModalContent extends StatefulWidget { + final Map<String, dynamic> activity; + final String conditionReason; + final bool isCompleted; + final VoidCallback onComplete; + + const HomeActivityModalContent({ + super.key, + required this.activity, + required this.conditionReason, + required this.isCompleted, + required this.onComplete, + }); + + @override + State<HomeActivityModalContent> createState() => + HomeActivityModalContentState(); +} + +class HomeActivityModalContentState extends State<HomeActivityModalContent> { + bool _isBreathPacerActive = false; + int _breathTimerSec = 0; + String _breathPhase = 'Tarik Napas'; + Timer? _breathTimer; + + @override + void dispose() { + _breathTimer?.cancel(); + super.dispose(); + } + + void _toggleBreathPacer() { + if (_isBreathPacerActive) { + _breathTimer?.cancel(); + setState(() { + _isBreathPacerActive = false; + _breathTimerSec = 0; + _breathPhase = 'Selesai'; + }); + } else { + setState(() { + _isBreathPacerActive = true; + _breathTimerSec = 0; + _breathPhase = 'Tarik Napas (4 dtk)'; + }); + + _breathTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return; + setState(() { + _breathTimerSec++; + final cycle = _breathTimerSec % 19; + if (cycle < 4) { + _breathPhase = 'Tarik Napas Perlahan (4s)'; + } else if (cycle < 11) { + _breathPhase = 'Tahan Napas di Dada (7s)'; + } else { + _breathPhase = 'Hembuskan Napas Rileks (8s)'; + } + }); + }); + } + } + + String _normalizeCategory(String? raw) { + final cat = (raw ?? '').toLowerCase(); + if (cat.contains('mindful') || cat.contains('napas') || cat.contains('breath')) return 'Mindfulness'; + if (cat.contains('cbt') || cat.contains('kognitif') || cat.contains('problem')) return 'CBT'; + if (cat.contains('refleksi') || cat.contains('syukur') || cat.contains('self-care')) return 'Refleksi'; + if (cat.contains('fisik') || cat.contains('gerak') || cat.contains('somatis') || cat.contains('aktivasi') || cat.contains('jalan')) return 'Fisik & Gerak'; + return raw?.isNotEmpty == true ? raw! : 'Self-Care'; + } + + @override + Widget build(BuildContext context) { + final title = widget.activity['title']?.toString() ?? 'Latihan Relaksasi'; + final rawCat = widget.activity['category']?.toString() ?? 'Self-Care'; + final category = _normalizeCategory(rawCat); + final duration = widget.activity['duration']?.toString() ?? '5 menit'; + final difficulty = widget.activity['difficulty']?.toString() ?? 'Pemula'; + final description = widget.activity['description']?.toString() ?? ''; + final rationale = widget.activity['rationale']?.toString() ?? ''; + final rawInstructions = widget.activity['instructions']; + List<String> instructions = rawInstructions is List + ? List<String>.from(rawInstructions.map((e) => e.toString())) + : <String>[]; + + if (instructions.isEmpty) { + instructions = [ + 'Cari posisi duduk yang santai dan nyaman.', + 'Tarik napas dalam perlahan dan hembuskan perlahan.', + 'Lakukan langkah latihan ini secara tenang selama $duration.', + 'Rasakan perubahan sensasi rileks di tubuhmu setelah selesai.' + ]; + } + + final displayRationale = + rationale.isNotEmpty ? rationale : widget.conditionReason; + + final isBreathingActivity = title.toLowerCase().contains('napas') || + title.toLowerCase().contains('breath') || + category.toLowerCase() == 'mindfulness'; + + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: const EdgeInsets.fromLTRB(22, 14, 22, 24), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.88, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + + // Header tags & close button (X) + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + category.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 8), + Text( + '• $duration • $difficulty', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 8), + + // Title + Text( + title, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + + // Description + Text( + description, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 14), + + // Scrollable Body + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Rationale Card + if (displayRationale.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFDDD6FE)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.lightbulb_outline, + size: 18, + color: Color(0xFF7C3AED), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Kenapa Latihan Ini Membantu?', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF5B21B6), + ), + ), + const SizedBox(height: 3), + Text( + displayRationale, + style: GoogleFonts.inter( + fontSize: 11.5, + height: 1.4, + color: const Color(0xFF6D28D9), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // 2. Interactive Breath Pacer (if relevant) + if (isBreathingActivity) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFFEEF2FF), + Color(0xFFE0E7FF), + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.air, + size: 16, color: Color(0xFF4F46E5)), + const SizedBox(width: 6), + Text( + 'Pemandu Irama Napas', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF3730A3), + ), + ), + ], + ), + GestureDetector( + onTap: _toggleBreathPacer, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: _isBreathPacerActive + ? const Color(0xFFEF4444) + : const Color(0xFF4F46E5), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _isBreathPacerActive + ? 'Hentikan' + : 'Mulai Pemandu', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + if (_isBreathPacerActive) ...[ + const SizedBox(height: 14), + AnimatedContainer( + duration: const Duration(milliseconds: 700), + width: _breathPhase.contains('Tarik') ? 80 : 65, + height: _breathPhase.contains('Tarik') ? 80 : 65, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF6366F1) + .withValues(alpha: 0.2), + border: Border.all( + color: const Color(0xFF4F46E5), width: 2), + ), + child: Center( + child: Text( + '${_breathTimerSec}s', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: const Color(0xFF3730A3), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + _breathPhase, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF4338CA), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + ], + + // 3. Step-by-step instructions + Text( + 'Langkah-Langkah yang Perlu Dilakukan:', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: instructions.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, idx) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.primaryContainer, + shape: BoxShape.circle, + ), + child: Text( + '${idx + 1}', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + instructions[idx], + style: GoogleFonts.inter( + fontSize: 13, + height: 1.4, + color: AppColors.textPrimary, + ), + ), + ), + ], + ); + }, + ), + const SizedBox(height: 20), + ], + ), + ), + ), + + // Completion Button CTA + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: widget.onComplete, + icon: Icon( + widget.isCompleted ? Icons.check_circle : Icons.check, + color: Colors.white, + size: 18, + ), + label: Text( + widget.isCompleted + ? 'Sudah Diselesaikan Hari Ini' + : 'Saya Sudah Melakukan Latihan Ini', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: widget.isCompleted + ? const Color(0xFF10B981) + : AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_crisis_banner.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_crisis_banner.dart new file mode 100644 index 0000000..096b93b --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_crisis_banner.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// High-visibility crisis support banner displayed when severe distress indicators are detected. +class HomeCrisisBanner extends StatelessWidget { + final String crisisGuidance; + final String crisisHotline; + final VoidCallback onSupportTap; + + const HomeCrisisBanner({ + super.key, + required this.crisisGuidance, + required this.crisisHotline, + required this.onSupportTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFECACA), width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.red.withValues(alpha: 0.08), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: const BoxDecoration( + color: Color(0xFFFEE2E2), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.favorite, + color: Color(0xFFDC2626), + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Kamu Berharga & Tidak Sendirian', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w800, + color: const Color(0xFF991B1B), + ), + ), + Text( + 'Prioritas Utama: Keselamatan & Bantuan Nyata', + style: GoogleFonts.inter( + fontSize: 11, + color: const Color(0xFFB91C1C), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + crisisGuidance, + style: GoogleFonts.inter( + fontSize: 11.5, + height: 1.4, + color: const Color(0xFF7F1D1D), + ), + ), + const SizedBox(height: 6), + Text( + 'Hotline: $crisisHotline', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF991B1B), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: onSupportTap, + icon: const Icon(Icons.support_agent, size: 16, color: Colors.white), + label: Text( + 'Pusat Bantuan & Hotline Darurat', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFDC2626), + padding: const EdgeInsets.symmetric(vertical: 9), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_daily_progress_card.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_daily_progress_card.dart new file mode 100644 index 0000000..11170ef --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_daily_progress_card.dart @@ -0,0 +1,361 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../core/utils/responsive_layout_helper.dart'; +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +/// Card displaying the 3 evidence-based daily self-care habit routines with segmented progress bars. +class HomeDailyProgressCard extends StatelessWidget { + final int completedCount; + final bool isConversationDone; + final bool isDiaryDone; + final bool isExerciseDone; + final int todayConversationsCount; + final VoidCallback onTapConversation; + final VoidCallback onTapDiary; + final VoidCallback onTapExercise; + + const HomeDailyProgressCard({ + super.key, + required this.completedCount, + required this.isConversationDone, + required this.isDiaryDone, + required this.isExerciseDone, + required this.todayConversationsCount, + required this.onTapConversation, + required this.onTapDiary, + required this.onTapExercise, + }); + + @override + Widget build(BuildContext context) { + final progressFraction = (completedCount / 3.0).clamp(0.0, 1.0); + + String progressMotivation; + if (completedCount == 3) { + progressMotivation = + 'Luar biasa! Semua target perawatan dirimu tercapai hari ini 🌟'; + } else if (completedCount == 2) { + progressMotivation = + 'Tinggal 1 langkah lagi untuk melengkapi rutinitas terapeutikmu.'; + } else if (completedCount == 1) { + progressMotivation = + 'Kemajuan yang sangat baik! Terus jaga ritme emosionalmu.'; + } else { + progressMotivation = + 'Mulai harimu dengan satu langkah kecil penuh perhatian bersama EVA.'; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: completedCount == 3 + ? const Color(0xFF10B981).withValues(alpha: 0.15) + : AppColors.primary.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + completedCount == 3 ? Icons.stars_rounded : Icons.spa_rounded, + size: 16, + color: completedCount == 3 + ? const Color(0xFF10B981) + : AppColors.primary, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Perawatan Diri', + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: context.responsiveFont(15), + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: completedCount == 3 + ? const Color(0xFFD1FAE5) + : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: completedCount == 3 + ? const Color(0xFF10B981).withValues(alpha: 0.3) + : AppColors.primary.withValues(alpha: 0.15), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (completedCount == 3) ...[ + const Icon( + Icons.check_circle_rounded, + size: 13, + color: Color(0xFF047857), + ), + const SizedBox(width: 4), + ], + Text( + '$completedCount/3 Selesai', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: completedCount == 3 + ? const Color(0xFF047857) + : AppColors.primary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + // Premium Progress Container + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Segmented 3-Step Progress Bars + Row( + children: [ + Expanded( + child: _buildSegmentBar(isActive: isConversationDone), + ), + const SizedBox(width: 6), + Expanded( + child: _buildSegmentBar(isActive: isDiaryDone), + ), + const SizedBox(width: 6), + Expanded( + child: _buildSegmentBar(isActive: isExerciseDone), + ), + ], + ), + const SizedBox(height: 12), + + // Motivation & Percentage Banner + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: completedCount == 3 + ? const Color(0xFFF0FDF4) + : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: completedCount == 3 + ? const Color(0xFF86EFAC).withValues(alpha: 0.5) + : const Color(0xFFE2E8F0), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + completedCount == 3 + ? Icons.emoji_events_rounded + : Icons.auto_awesome, + size: 16, + color: completedCount == 3 + ? const Color(0xFF10B981) + : AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + progressMotivation, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: completedCount == 3 + ? const Color(0xFF065F46) + : AppColors.textSecondary, + height: 1.35, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: completedCount == 3 + ? const Color(0xFF10B981) + : AppColors.primary, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${(progressFraction * 100).round()}%', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + + // Routine 1: Sesi Curhat / Dialog Emosi + _buildProgressCheckItem( + title: 'Sesi Curhat Bersama Eva', + subtitle: isConversationDone + ? '$todayConversationsCount sesi dialog telah tercatat hari ini' + : 'Ekspresikan perasaanmu lewat suara atau teks', + icon: Icons.chat_bubble_outline_rounded, + isCompleted: isConversationDone, + onTap: onTapConversation, + ), + const SizedBox(height: 8), + + // Routine 2: Refleksi Jurnal AI + _buildProgressCheckItem( + title: 'Refleksi Jurnal Harian', + subtitle: isDiaryDone + ? 'Jurnal & analisis emosi telah disintesis' + : 'Baca rangkuman & pola emosimu hari ini', + icon: Icons.menu_book_rounded, + isCompleted: isDiaryDone, + onTap: onTapDiary, + ), + const SizedBox(height: 8), + + // Routine 3: Latihan Koping Terpandu + _buildProgressCheckItem( + title: 'Latihan Relaksasi & Koping', + subtitle: isExerciseDone + ? '1 latihan koping mandiri telah diselesaikan' + : 'Coba 1 teknik pernapasan atau mindfulness', + icon: Icons.spa_rounded, + isCompleted: isExerciseDone, + onTap: onTapExercise, + ), + ], + ), + ), + ], + ); + } + + Widget _buildSegmentBar({required bool isActive}) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 6, + decoration: BoxDecoration( + color: isActive ? AppColors.primary : const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ); + } + + Widget _buildProgressCheckItem({ + required String title, + required String subtitle, + required IconData icon, + required bool isCompleted, + required VoidCallback onTap, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isCompleted + ? AppColors.primaryContainer.withValues(alpha: 0.35) + : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isCompleted + ? AppColors.primary.withValues(alpha: 0.35) + : const Color(0xFFE2E8F0), + width: 1, + ), + ), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 250), + width: 22, + height: 22, + decoration: BoxDecoration( + color: isCompleted ? AppColors.primary : Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: isCompleted ? AppColors.primary : const Color(0xFFCBD5E1), + width: 1.5, + ), + ), + child: isCompleted + ? const Icon(Icons.check, size: 14, color: Colors.white) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: isCompleted + ? AppColors.primary + : AppColors.textPrimary, + decoration: + isCompleted ? TextDecoration.lineThrough : null, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textLight, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + icon, + size: 18, + color: isCompleted ? AppColors.primary : const Color(0xFF94A3B8), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_eva_insight_card.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_eva_insight_card.dart new file mode 100644 index 0000000..9be0f96 --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_eva_insight_card.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; + +/// Card displaying empathetic psychological insight from Eva AI based on daily reflections. +class HomeEvaInsightCard extends StatelessWidget { + final String insightText; + + const HomeEvaInsightCard({ + super.key, + required this.insightText, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: const LinearGradient( + colors: [ + Color(0xFFEDE9FE), + Color(0xFFF3E8FF), + Color(0xFFE0E7FF), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.08), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + border: Border.all( + color: Colors.white.withValues(alpha: 0.9), + width: 1.5, + ), + ), + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.auto_awesome, + size: 15, + color: AppColors.primary, + ), + ), + const SizedBox(width: 8), + Text( + 'CATATAN DARI EVA', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + '"$insightText"', + style: GoogleFonts.inter( + fontSize: 13, + fontStyle: FontStyle.italic, + height: 1.5, + fontWeight: FontWeight.w500, + color: const Color(0xFF333878), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_quick_actions.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_quick_actions.dart new file mode 100644 index 0000000..ecf393c --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_quick_actions.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +/// Shortcuts grid for core mental health features (Chat, Diary, DASS-21 Assessment). +class HomeQuickActions extends StatelessWidget { + final VoidCallback onTapChat; + final VoidCallback onTapDiary; + final VoidCallback onTapDass; + final String dassSubtitle; + final bool showDassNotificationDot; + + const HomeQuickActions({ + super.key, + required this.onTapChat, + required this.onTapDiary, + required this.onTapDass, + required this.dassSubtitle, + this.showDassNotificationDot = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _buildQuickCard( + icon: Icons.chat_bubble_outline, + iconBg: const Color(0xFF5358CB), + iconColor: Colors.white, + title: 'Curhat ke EVA', + subtitle: 'Mulai dialog suara atau teks', + onTap: onTapChat, + ), + const SizedBox(height: 12), + _buildQuickCard( + icon: Icons.menu_book_outlined, + iconBg: const Color(0xFFE2DAFF), + iconColor: const Color(0xFF5358CB), + title: 'Jurnal Refleksi AI', + subtitle: 'Lihat rangkuman emosi harimu', + onTap: onTapDiary, + ), + const SizedBox(height: 12), + _buildQuickCard( + icon: Icons.psychology_outlined, + iconBg: const Color(0xFFE0F4FB), + iconColor: const Color(0xFF20667B), + title: 'Asesmen DASS-21', + subtitle: dassSubtitle, + showNotificationDot: showDassNotificationDot, + onTap: onTapDass, + ), + ], + ); + } + + Widget _buildQuickCard({ + required IconData icon, + required Color iconBg, + required Color iconColor, + required String title, + required String subtitle, + bool showNotificationDot = false, + required VoidCallback onTap, + }) { + return GlassCard( + width: double.infinity, + onTap: onTap, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: iconBg, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: iconColor, size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + if (showNotificationDot) ...[ + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFF59E0B), + shape: BoxShape.circle, + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textLight, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right, + size: 20, + color: AppColors.textLight, + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_recommendations_section.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_recommendations_section.dart new file mode 100644 index 0000000..55359c9 --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_recommendations_section.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_snack_bar.dart'; +import 'home_crisis_banner.dart'; + +/// Section displaying personalized therapeutic CBT & mindfulness activities for the day. +class HomeRecommendationsSection extends StatelessWidget { + final Map<String, dynamic> activeRec; + final String conditionLabel; + final String severityLevel; + final bool isCrisis; + final String crisisGuidance; + final String crisisHotline; + final bool isDone; + final VoidCallback onSeeAll; + final VoidCallback onSupportTap; + final VoidCallback onOpenDetail; + final ValueChanged<String> onToggleComplete; + + const HomeRecommendationsSection({ + super.key, + required this.activeRec, + required this.conditionLabel, + required this.severityLevel, + required this.isCrisis, + required this.crisisGuidance, + required this.crisisHotline, + required this.isDone, + required this.onSeeAll, + required this.onSupportTap, + required this.onOpenDetail, + required this.onToggleComplete, + }); + + static Color getSeverityColor(String severity) { + switch (severity.toLowerCase()) { + case 'sangat berat': + case 'extremely severe': + return const Color(0xFFDC2626); + case 'berat': + case 'severe': + return const Color(0xFFEA580C); + case 'sedang': + case 'moderate': + return const Color(0xFFD97706); + case 'ringan': + case 'mild': + return const Color(0xFF2563EB); + default: + return const Color(0xFF10B981); + } + } + + static String normalizeCategory(String cat) { + if (cat.toUpperCase() == 'CBT') return 'Teknik Kognitif (CBT)'; + if (cat.toUpperCase() == 'MINDFULNESS') return 'Latihan Mindfulness'; + if (cat.toUpperCase() == 'BREATHING') return 'Pernapasan Terarah'; + if (cat.toUpperCase() == 'REFLEKSI') return 'Jurnal Refleksi Diri'; + return cat; + } + + @override + Widget build(BuildContext context) { + final id = activeRec['id']?.toString() ?? '1'; + final rawCategory = activeRec['category']?.toString() ?? 'Self-Care'; + final category = normalizeCategory(rawCategory); + final duration = activeRec['duration']?.toString() ?? '5 menit'; + final title = activeRec['title']?.toString() ?? 'Latihan Relaksasi'; + final description = activeRec['description']?.toString() ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + 'Aktivitas Pilihan Hari Ini', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + GestureDetector( + onTap: onSeeAll, + child: Row( + children: [ + Text( + 'Lihat Semua', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + const SizedBox(width: 2), + const Icon( + Icons.arrow_forward_ios, + size: 10, + color: AppColors.primary, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + conditionLabel.isNotEmpty + ? conditionLabel + : 'Disesuaikan dengan kondisi emosionalmu hari ini', + style: GoogleFonts.inter( + fontSize: 12.5, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + + // Crisis Alert Banner (if applicable) + if (isCrisis) + HomeCrisisBanner( + crisisGuidance: crisisGuidance, + crisisHotline: crisisHotline, + onSupportTap: onSupportTap, + ), + + // Sleek Recommendation Card + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: const LinearGradient( + colors: [ + Color(0xFF4F46E5), + Color(0xFF6366F1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF4F46E5).withValues(alpha: 0.22), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top Tag Row: Category • Duration + Severity Pill + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$category • $duration'.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 0.5, + ), + ), + ), + ), + if (severityLevel != 'Normal') + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: getSeverityColor(severityLevel).withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: getSeverityColor(severityLevel).withValues(alpha: 0.5), + ), + ), + child: Text( + 'Level: $severityLevel', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Title + Text( + title, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + + // Description + Text( + description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12.5, + height: 1.45, + color: Colors.white.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: 16), + + // Bottom Action Buttons: Mulai Latihan (CTA) + Tandai Selesai + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: onOpenDetail, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 11), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.play_circle_filled, + size: 18, + color: Color(0xFF4F46E5), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + isDone ? 'Buka Panduan' : 'Mulai Latihan', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF4F46E5), + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: () { + if (isDone) { + AppSnackBar.showPrimary( + context, + 'Kamu sudah menyelesaikan latihan ini hari ini! Kerja bagus, istirahatlah sejenak dan lanjutkan kembali besok 🌿', + icon: Icons.info_outline, + ); + return; + } + onToggleComplete(id); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isDone + ? const Color(0xFF10B981) + : Colors.white.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDone + ? const Color(0xFF10B981) + : Colors.white.withValues(alpha: 0.35), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isDone ? Icons.check_circle : Icons.check_circle_outline, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 6), + Text( + isDone ? 'Selesai' : 'Tandai', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/profile_dropdown_modal.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/profile_dropdown_modal.dart new file mode 100644 index 0000000..37695ac --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/profile_dropdown_modal.dart @@ -0,0 +1,365 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/features/coin/presentation/providers/coin_provider.dart'; + +/// Floating profile dropdown modal inspired by modern glass design, +/// styled specifically with Eva AI purple theme and interactive coin balance banner. +class ProfileDropdownModal extends ConsumerWidget { + final String userName; + final String userEmail; + final int? coinBalance; + final VoidCallback onProfileTap; + final VoidCallback onEmergencyContactsTap; + final VoidCallback onSettingsTap; + final VoidCallback onLogoutTap; + final VoidCallback? onCoinTap; + + const ProfileDropdownModal({ + super.key, + required this.userName, + required this.userEmail, + this.coinBalance, + required this.onProfileTap, + required this.onEmergencyContactsTap, + required this.onSettingsTap, + required this.onLogoutTap, + this.onCoinTap, + }); + + String get _userInitials { + final parts = userName.trim().split(RegExp(r'\s+')); + if (parts.length >= 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } else if (parts.isNotEmpty && parts[0].isNotEmpty) { + return parts[0].substring(0, parts[0].length >= 2 ? 2 : 1).toUpperCase(); + } + return 'SA'; + } + + void _handleCoinTap(BuildContext context) { + if (onCoinTap != null) { + onCoinTap!(); + } else { + Navigator.of(context, rootNavigator: true).pop(); + Navigator.pushNamed(context, '/coin_purchase'); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final effectiveCoins = coinBalance ?? ref.watch(coinBalanceProvider); + + return Container( + width: 295, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: const Color(0xFFE2E8F0), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 28, + offset: const Offset(0, 10), + ), + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.08), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 1. User Info Header + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [Color(0xFF8B93FF), Color(0xFF5358CB)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: Border.all( + color: const Color(0xFFE0E7FF), + width: 2, + ), + ), + child: Center( + child: Text( + _userInitials, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + userName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + userEmail.isNotEmpty ? userEmail : 'samsul@gmail.com', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 14), + + // 2. Coin Balance Gradient Banner (Purple theme, interactive coin top up) + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _handleCoinTap(context), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF5358CB), // AppColors.primary + Color(0xFF8B93FF), // AppColors.primaryLight + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5358CB).withValues(alpha: 0.28), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.monetization_on_rounded, + color: Color(0xFFFFD54F), + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Saldo Koin', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: 1), + Text( + '$effectiveCoins Koin', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.2, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(999), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.add_circle_outline_rounded, + size: 13, + color: Color(0xFF5358CB), + ), + const SizedBox(width: 4), + Text( + 'Beli', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFF5358CB), + ), + ), + ], + ), + ), + const SizedBox(width: 6), + Icon( + Icons.arrow_forward_ios_rounded, + size: 11, + color: Colors.white.withValues(alpha: 0.8), + ), + ], + ), + ), + ), + ), + + const SizedBox(height: 12), + + // 3. Menu Item: User Profile + _buildMenuItem( + icon: Icons.person_outline_rounded, + title: 'User Profile', + onTap: onProfileTap, + ), + + // 4. Menu Item: Kontak Darurat + _buildMenuItem( + icon: Icons.contact_phone_outlined, + title: 'Kontak Darurat', + onTap: onEmergencyContactsTap, + ), + + // 5. Menu Item: Pengaturan + _buildMenuItem( + icon: Icons.settings_outlined, + title: 'Settings', + onTap: onSettingsTap, + ), + + const SizedBox(height: 6), + const Divider( + height: 16, + thickness: 1, + color: Color(0xFFF1F5F9), + ), + const SizedBox(height: 4), + + // 6. Menu Item: Keluar Akun + InkWell( + onTap: onLogoutTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: const Color(0xFFFEE2E2), + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.logout_rounded, + size: 18, + color: Color(0xFFEF4444), + ), + const SizedBox(width: 10), + Text( + 'Keluar Akun', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFFEF4444), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildMenuItem({ + required IconData icon, + required String title, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: const Color(0xFF64748B), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + const Icon( + Icons.arrow_forward_ios_rounded, + size: 12, + color: Color(0xFFCBD5E1), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/monitoring/data/repositories/fallback_monitoring_repository.dart b/apps/eva_mobile/lib/features/monitoring/data/repositories/fallback_monitoring_repository.dart new file mode 100644 index 0000000..75a7a0a --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/data/repositories/fallback_monitoring_repository.dart @@ -0,0 +1,23 @@ +import '../../domain/repositories/monitoring_repository.dart'; +import '../../domain/entities/monitoring_data_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackMonitoringRepository implements MonitoringRepository { + final MonitoringRepository _remote; + final MonitoringRepository _mock; + + FallbackMonitoringRepository({ + required MonitoringRepository remote, + required MonitoringRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future<MonitoringDataEntity> getMonitoringData(String periodKey) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getMonitoringData(periodKey), + mockCall: () => _mock.getMonitoringData(periodKey), + operationName: 'getMonitoringData($periodKey)', + ); + } +} diff --git a/apps/eva_mobile/lib/features/monitoring/presentation/providers/monitoring_provider.dart b/apps/eva_mobile/lib/features/monitoring/presentation/providers/monitoring_provider.dart index 2c8cefd..f57a7c1 100644 --- a/apps/eva_mobile/lib/features/monitoring/presentation/providers/monitoring_provider.dart +++ b/apps/eva_mobile/lib/features/monitoring/presentation/providers/monitoring_provider.dart @@ -1,16 +1,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/monitoring_repository.dart'; import '../../domain/entities/monitoring_data_entity.dart'; import '../../data/datasources/mock_monitoring_datasource.dart'; import '../../data/datasources/remote_monitoring_datasource.dart'; import '../../data/repositories/monitoring_repository_impl.dart'; +import '../../data/repositories/fallback_monitoring_repository.dart'; final monitoringRepositoryProvider = Provider<MonitoringRepository>((ref) { + final mockRepo = MonitoringRepositoryImpl(MockMonitoringDataSource()); + final remoteRepo = MonitoringRepositoryImpl(RemoteMonitoringDataSource()); + if (AppConfig.useMockData) { - return MonitoringRepositoryImpl(MockMonitoringDataSource()); + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackMonitoringRepository(remote: remoteRepo, mock: mockRepo); } else { - return MonitoringRepositoryImpl(RemoteMonitoringDataSource()); + return remoteRepo; } }); diff --git a/apps/eva_mobile/lib/features/monitoring/presentation/screens/monitoring_screen.dart b/apps/eva_mobile/lib/features/monitoring/presentation/screens/monitoring_screen.dart new file mode 100644 index 0000000..5b8d35a --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/presentation/screens/monitoring_screen.dart @@ -0,0 +1,787 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/core/utils/responsive_layout_helper.dart'; +import 'package:eva_mobile/features/dass/presentation/providers/dass_provider.dart'; +import 'package:eva_mobile/features/monitoring/domain/entities/monitoring_data_entity.dart'; +import 'package:eva_mobile/features/monitoring/presentation/providers/monitoring_provider.dart'; +import 'package:eva_mobile/features/monitoring/presentation/widgets/monitoring_dass_card.dart'; +import 'package:eva_mobile/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/skeleton_shimmer.dart'; + +class MonitoringScreen extends ConsumerStatefulWidget { + final GlobalKey? dassCardKey; + + const MonitoringScreen({ + super.key, + this.dassCardKey, + }); + + @override + ConsumerState<MonitoringScreen> createState() => MonitoringScreenState(); +} + +class MonitoringScreenState extends ConsumerState<MonitoringScreen> + with WidgetsBindingObserver { + late final GlobalKey _defaultDassCardKey = GlobalKey(debugLabel: 'monitoringDassCard'); + GlobalKey get dassCardKey => widget.dassCardKey ?? _defaultDassCardKey; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + refresh(); + } + } + + void refresh() { + ref.invalidate(monitoringDataProvider); + ref.invalidate(dassAssessmentNotifierProvider); + } + + // 7 Emotion parameters color mapping matching backend & design system + final List<Map<String, dynamic>> _emotionLegend = const [ + {'key': 'happy', 'name': 'Bahagia & Senang', 'emoji': '😃', 'color': Color(0xFFFFB800)}, + {'key': 'netral', 'name': 'Netral & Tenang', 'emoji': '😌', 'color': Color(0xFF4ECDC4)}, + {'key': 'fear', 'name': 'Cemas & Takut', 'emoji': '😰', 'color': Color(0xFF6C63FF)}, + {'key': 'sadness', 'name': 'Sedih', 'emoji': '😢', 'color': Color(0xFF74B9FF)}, + {'key': 'surprise', 'name': 'Terkejut', 'emoji': '😲', 'color': Color(0xFFA29BFE)}, + {'key': 'anger', 'name': 'Marah', 'emoji': '😡', 'color': Color(0xFFFF7675)}, + {'key': 'disgusted', 'name': 'Jijik / Muak', 'emoji': '🤢', 'color': Color(0xFF55EFC4)}, + ]; + + Color _parseHex(String? hex, Color fallback) { + if (hex == null || hex.isEmpty) return fallback; + try { + final clean = hex.replaceFirst('#', ''); + if (clean.length == 6) { + return Color(int.parse('FF$clean', radix: 16)); + } + return Color(int.parse(clean, radix: 16)); + } catch (_) { + return fallback; + } + } + + List<Color> _getGradientColors(int level) { + switch (level) { + case 5: + return const [Color(0xFFC8E6C9), Color(0xFF81C784)]; + case 4: + return const [Color(0xFFE8F5E9), Color(0xFFA5D6A7)]; + case 3: + return const [Color(0xFFFFF8E1), Color(0xFFFFE082)]; + case 2: + return const [Color(0xFFFFE0B2), Color(0xFFFFB74D)]; + case 1: + return const [Color(0xFFFFEBEE), Color(0xFFFFCDD2)]; + case 0: + default: + return const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)]; + } + } + + @override + Widget build(BuildContext context) { + final selectedPeriod = ref.watch(selectedPeriodProvider); + final asyncData = ref.watch(monitoringDataProvider); + + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: () async { + ref.invalidate(monitoringDataProvider); + try { + await ref.read(monitoringDataProvider.future); + } catch (_) {} + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20.0, 16.0, 20.0, 100.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title & Subtitle + Text( + 'Ritem & Tren Emosional', + style: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Grafik dinamika 7 emosi dan tingkat risiko kesehatan mentalmu.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 16), + + // PERIOD FILTER CHIPS + Row( + children: [ + _buildPeriodChip('today', 'Hari Ini', selectedPeriod), + const SizedBox(width: 8), + _buildPeriodChip('week', 'Minggu Ini', selectedPeriod), + const SizedBox(width: 8), + _buildPeriodChip('month', 'Bulan Ini', selectedPeriod), + ], + ), + const SizedBox(height: 20), + + // ASYNC CONTENT + asyncData.when( + data: (data) => _buildMonitoringContent(context, data), + loading: () => _buildLoadingState(), + error: (error, _) => _buildErrorState(error), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildPeriodChip(String id, String label, String activePeriod) { + final isSelected = id == activePeriod; + return Expanded( + child: GestureDetector( + onTap: () { + ref.read(selectedPeriodProvider.notifier).state = id; + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(999), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: Text( + label, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: isSelected ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ), + ); + } + + Widget _buildMonitoringContent(BuildContext context, MonitoringDataEntity data) { + final emotionalCenter = data.emotionalCenter; + final int level = emotionalCenter.level; + final Color textColor = _parseHex(emotionalCenter.textColorHex, const Color(0xFF2E7D32)); + final List<Color> gradientColors = _getGradientColors(level); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. RINGKASAN AI + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: Icon + Title + Period Badge + Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.auto_awesome, + size: 16, + color: AppColors.primary, + ), + ), + const SizedBox(width: 10), + Text( + 'RINGKASAN AI', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: AppColors.primary, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.15), + width: 1, + ), + ), + child: Text( + data.periodLabel, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + // Body content text cleanly below icon and title + Text( + data.summary, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + height: 1.55, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // 2. LEMBAR EVALUASI DASS-21 + MonitoringDassCard( + key: dassCardKey, + selectedPeriod: ref.watch(selectedPeriodProvider), + ), + const SizedBox(height: 16), + + // 3. PUSAT EMOSIONAL HARIAN + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: gradientColors.last.withValues(alpha: 0.3), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.tune_rounded, + size: 20, + color: textColor, + ), + const SizedBox(width: 8), + Text( + 'PUSAT EMOSIONAL', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: textColor.withValues(alpha: 0.8), + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + emotionalCenter.status, + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w800, + color: textColor, + ), + ), + const SizedBox(height: 4), + Text( + emotionalCenter.description, + style: GoogleFonts.inter( + fontSize: 12, + color: textColor.withValues(alpha: 0.9), + ), + ), + const SizedBox(height: 16), + + // 5-Segment Level Pills (Red to Green Spectrum) + Row( + children: List.generate(5, (index) { + final segLevel = index + 1; + final bool isSel = segLevel == level; + final colors = const [ + Color(0xFFD32F2F), // 1: Sangat Buruk (Red) + Color(0xFFE57373), // 2: Buruk + Color(0xFFFFB74D), // 3: Cukup (Amber) + Color(0xFF81C784), // 4: Baik (Light Green) + Color(0xFF4CAF50), // 5: Sangat Baik (Green) + ]; + + return Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + height: 28, + decoration: BoxDecoration( + color: colors[index].withValues(alpha: isSel ? 1.0 : 0.4), + borderRadius: BorderRadius.circular(10), + border: isSel ? Border.all(color: Colors.white, width: 2) : null, + ), + child: isSel + ? const Icon(Icons.check, color: Colors.white, size: 16) + : null, + ), + ); + }), + ), + ], + ), + ), + const SizedBox(height: 16), + + // 4. TINGKAT RISIKO KESEHATAN MENTAL + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.health_and_safety_outlined, + size: 20, + color: Color(0xFFD32F2F), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'TINGKAT RISIKO KESEHATAN MENTAL', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + + ...data.risks.map((r) { + final double pct = r.percent; + final Color barColor = _parseHex(r.colorHex, const Color(0xFFFB8C00)); + final Color badgeBg = _parseHex(r.badgeBgHex, const Color(0xFFFFF3E0)); + + return Padding( + padding: const EdgeInsets.only(bottom: 14.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + r.name, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: badgeBg, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + r.levelLabel, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: barColor, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: pct, + minHeight: 8, + backgroundColor: const Color(0xFFE2E4F0), + valueColor: AlwaysStoppedAnimation<Color>(barColor), + ), + ), + ], + ), + ); + }), + const SizedBox(height: 4), + Text( + 'EVA merekomendasikan jeda istirahat teratur dan latihan pernapasan untuk menstabilkan kondisi psikologis.', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // 5. STACKED BAR CHART RITEM SUASANA HATI (7 Emosi) + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ritem 7 Emosi', + style: GoogleFonts.inter( + fontSize: context.responsiveFont(12), + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 2), + Text( + 'Proporsi emosi per interval ${data.periodLabel}', + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFEADBFF), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '7 PARAMETER', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + + // Custom Painted Vertical Stacked Bar Chart + SizedBox( + height: 160, + width: double.infinity, + child: RepaintBoundary( + child: CustomPaint( + painter: StackedEmotionChartPainter( + chartData: data.chartData, + emotionColors: _emotionLegend.map((e) => e['color'] as Color).toList(), + ), + ), + ), + ), + const SizedBox(height: 12), + + // X-Axis Labels Row + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: data.xLabels.map((lbl) { + return Flexible( + child: Text( + lbl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textLight, + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 18), + + const Divider(height: 1, color: Color(0xFFEBECEF)), + const SizedBox(height: 14), + + // Legend Key for 7 Emotions + Text( + 'KUNCI WARNA EMOSI', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 8), + + Wrap( + spacing: 8, + runSpacing: 6, + children: _emotionLegend.map((item) { + final Color solidColor = item['color'] as Color; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFFE2E8F0), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: solidColor, + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 6), + Text(item['emoji'], style: const TextStyle(fontSize: 12)), + const SizedBox(width: 4), + Text( + item['name'], + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + ); + }).toList(), + ), + ], + ), + ), + ], + ); + } + + + Widget _buildLoadingState() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Ringkasan AI Skeleton + SkeletonShimmerHost( + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SkeletonCircle(size: 32), + const SizedBox(width: 10), + const SkeletonLine(width: 120, height: 14), + ], + ), + const SizedBox(height: 14), + const SkeletonLine(width: double.infinity, height: 13), + const SizedBox(height: 8), + const SkeletonLine(width: 200, height: 13), + ], + ), + ), + ), + const SizedBox(height: 16), + + // 2. Lembar Evaluasi DASS-21 (mounted immediately with key) + MonitoringDassCard( + key: dassCardKey, + selectedPeriod: ref.watch(selectedPeriodProvider), + ), + const SizedBox(height: 16), + + // 3. Trend Chart & Metrics Skeleton + SkeletonShimmerHost( + child: Column( + children: [ + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + SkeletonLine(width: 140, height: 16), + SkeletonBox(width: 70, height: 26, borderRadius: 8), + ], + ), + const SizedBox(height: 20), + const SkeletonBox(width: double.infinity, height: 160, borderRadius: 12), + ], + ), + ), + const SizedBox(height: 18), + Row( + children: [ + Expanded( + child: GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + children: const [ + SkeletonLine(width: 60, height: 12), + SizedBox(height: 8), + SkeletonLine(width: 40, height: 18), + ], + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + children: const [ + SkeletonLine(width: 60, height: 12), + SizedBox(height: 8), + SkeletonLine(width: 40, height: 18), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _buildErrorState(Object error) { + return GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + const Icon(Icons.cloud_off_rounded, size: 48, color: AppColors.textLight), + const SizedBox(height: 12), + Text( + 'Gagal Memuat Data Tren', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + 'Pastikan koneksi internet atau server backend aktif.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + ref.invalidate(monitoringDataProvider); + }, + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Coba Lagi'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/monitoring/presentation/widgets/monitoring_dass_card.dart b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/monitoring_dass_card.dart new file mode 100644 index 0000000..b68e8d2 --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/monitoring_dass_card.dart @@ -0,0 +1,952 @@ +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:google_fonts/google_fonts.dart"; +import "package:url_launcher/url_launcher.dart"; + +import "package:eva_mobile/theme/app_colors.dart"; +import "package:eva_mobile/widgets/app_snack_bar.dart"; +import "package:eva_mobile/widgets/glass_card.dart"; +import "../../../dass/presentation/providers/dass_provider.dart"; +import "../../../tutorial/domain/entities/tutorial_step.dart"; +import "../../../tutorial/presentation/providers/tutorial_provider.dart"; + +class MonitoringDassCard extends ConsumerWidget { + final String selectedPeriod; + + const MonitoringDassCard({ + super.key, + required this.selectedPeriod, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final dassState = ref.watch(dassAssessmentNotifierProvider); + + return dassState.assessment.when( + data: (assessment) { + final bool hasValidAssessment = assessment.id != null && + assessment.status != "unassessed" && + assessment.status.isNotEmpty && + (assessment.verifiedByUser || assessment.status == "auto_extracted"); + + final String subtitleText = hasValidAssessment + ? (assessment.verifiedByUser + ? "Skor DASS-21 telah diverifikasi ✓" + : "Tersintesis otomatis • Ketuk untuk meninjau") + : "Belum ada evaluasi hari ini"; + + final int? stressScore = hasValidAssessment ? assessment.stressScore : null; + final String stressSeverity = hasValidAssessment ? assessment.stressSeverity : "unassessed"; + + final int? anxietyScore = hasValidAssessment ? assessment.anxietyScore : null; + final String anxietySeverity = hasValidAssessment ? assessment.anxietySeverity : "unassessed"; + + final int? depressionScore = hasValidAssessment ? assessment.depressionScore : null; + final String depressionSeverity = hasValidAssessment ? assessment.depressionSeverity : "unassessed"; + + return _buildDASSLauncherCardContainer( + context: context, + ref: ref, + selectedPeriod: selectedPeriod, + subtitleText: subtitleText, + hasValidAssessment: hasValidAssessment, + stressScore: stressScore, + stressSeverity: stressSeverity, + anxietyScore: anxietyScore, + anxietySeverity: anxietySeverity, + depressionScore: depressionScore, + depressionSeverity: depressionSeverity, + ); + }, + loading: () => _buildDASSLauncherCardContainer( + context: context, + ref: ref, + selectedPeriod: selectedPeriod, + subtitleText: "Memuat data evaluasi DASS-21...", + hasValidAssessment: false, + stressScore: null, + stressSeverity: "unassessed", + anxietyScore: null, + anxietySeverity: "unassessed", + depressionScore: null, + depressionSeverity: "unassessed", + ), + error: (_, _) => _buildDASSLauncherCardContainer( + context: context, + ref: ref, + selectedPeriod: selectedPeriod, + subtitleText: "Belum ada evaluasi hari ini", + hasValidAssessment: false, + stressScore: null, + stressSeverity: "unassessed", + anxietyScore: null, + anxietySeverity: "unassessed", + depressionScore: null, + depressionSeverity: "unassessed", + ), + ); + } + + Widget _buildDASSLauncherCardContainer({ + required BuildContext context, + required WidgetRef ref, + required String selectedPeriod, + required String subtitleText, + required bool hasValidAssessment, + required int? stressScore, + required String stressSeverity, + required int? anxietyScore, + required String anxietySeverity, + required int? depressionScore, + required String depressionSeverity, + }) { + return GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFE0F4FB), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.psychology_outlined, + color: Color(0xFF20667B), + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'LEMBAR EVALUASI DASS-21', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const Spacer(), + InkWell( + onTap: () => _showDassInfoDialog(context), + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(5), + decoration: const BoxDecoration( + color: Color(0xFFE8F0FE), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.info_outline, + size: 16, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + subtitleText, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + + // Mini Subscale Summary Pills + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + _buildSubscaleMiniChip( + context, + 'Stres', + stressScore, + stressSeverity, + 'stress', + ), + const SizedBox(width: 8), + _buildSubscaleMiniChip( + context, + 'Kecemasan', + anxietyScore, + anxietySeverity, + 'anxiety', + ), + const SizedBox(width: 8), + _buildSubscaleMiniChip( + context, + 'Depresi', + depressionScore, + depressionSeverity, + 'depression', + ), + ], + ), + ), + const SizedBox(height: 14), + + // Open Full Assessment Button + SizedBox( + width: double.infinity, + height: 44, + child: ElevatedButton.icon( + onPressed: () async { + final tutorialState = ref.read(tutorialNotifierProvider); + if (tutorialState.isActive && + tutorialState.currentStep == TutorialStep.analytics) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.analytics); + } + await Navigator.pushNamed( + context, + '/dass_assessment', + arguments: {'period': selectedPeriod}, + ); + ref.invalidate(dassAssessmentNotifierProvider); + }, + icon: Icon( + hasValidAssessment + ? Icons.assignment_turned_in_outlined + : Icons.edit_note_rounded, + size: 18, + ), + label: Text( + hasValidAssessment + ? (selectedPeriod == 'today' + ? 'Tinjau Asesmen Hari Ini' + : 'Buka Riwayat Asesmen') + : 'Mulai Evaluasi DASS-21', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + ), + ), + ], + ), + ); + } + + String _getIndonesianSeverity(String scaleType, int score, String rawSeverity) { + final s = rawSeverity.toLowerCase(); + if (s.contains('extremely') || s.contains('sangat berat')) return 'Sangat Berat'; + if (s.contains('severe') || s.contains('berat')) return 'Berat'; + if (s.contains('moderate') || s.contains('sedang')) return 'Sedang'; + if (s.contains('mild') || s.contains('ringan')) return 'Ringan'; + if (s.contains('normal')) return 'Normal'; + + if (scaleType == 'depression') { + if (score >= 28) return 'Sangat Berat'; + if (score >= 21) return 'Berat'; + if (score >= 14) return 'Sedang'; + if (score >= 10) return 'Ringan'; + return 'Normal'; + } else if (scaleType == 'anxiety') { + if (score >= 20) return 'Sangat Berat'; + if (score >= 15) return 'Berat'; + if (score >= 10) return 'Sedang'; + if (score >= 8) return 'Ringan'; + return 'Normal'; + } else { + if (score >= 34) return 'Sangat Berat'; + if (score >= 26) return 'Berat'; + if (score >= 19) return 'Sedang'; + if (score >= 15) return 'Ringan'; + return 'Normal'; + } + } + + Color _getScoreColor(String severityIndo) { + switch (severityIndo) { + case 'Sangat Berat': + return const Color(0xFFD32F2F); + case 'Berat': + return const Color(0xFFE65100); + case 'Sedang': + return const Color(0xFFF57C00); + case 'Ringan': + return const Color(0xFFFBC02D); + case 'Normal': + default: + return const Color(0xFF2E7D32); + } + } + + Widget _buildSubscaleMiniChip( + BuildContext context, + String label, + int? score, + String rawSeverity, + String scaleType, + ) { + if (score == null) { + return Expanded( + child: GestureDetector( + onTap: () => _showDassInfoDialog(context), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FE), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: const Color(0xFFE5E7EB), + width: 1, + ), + ), + child: Column( + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: const Color(0xFF6B7280), + ), + ), + const SizedBox(height: 3), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '-', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w900, + color: const Color(0xFF9CA3AF), + ), + ), + Text( + '/42', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w600, + color: const Color(0xFF9CA3AF), + ), + ), + ], + ), + const SizedBox(height: 2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Belum Ada', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w700, + color: const Color(0xFF9CA3AF), + ), + ), + ), + ], + ), + ), + ), + ); + } + + final severityIndo = _getIndonesianSeverity(scaleType, score, rawSeverity); + final scoreColor = _getScoreColor(severityIndo); + final bool isExtremelySevere = severityIndo == 'Sangat Berat'; + + final Color bgColor = isExtremelySevere + ? const Color(0xFFD32F2F) + : scoreColor.withValues(alpha: 0.08); + + final Color labelColor = isExtremelySevere ? Colors.white70 : scoreColor; + + return Expanded( + child: GestureDetector( + onTap: () { + if (isExtremelySevere) { + _showPsychologistReferralModal(context, label, score, scaleType); + } else { + _showSubscaleInfoModal(context, label, score, severityIndo, scaleType); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isExtremelySevere + ? const Color(0xFFB71C1C) + : scoreColor.withValues(alpha: 0.25), + width: isExtremelySevere ? 1.5 : 1, + ), + boxShadow: isExtremelySevere + ? [ + BoxShadow( + color: const Color(0xFFD32F2F).withValues(alpha: 0.35), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isExtremelySevere) ...[ + const Icon(Icons.warning_amber_rounded, size: 12, color: Colors.white), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: labelColor, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$score', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w900, + color: isExtremelySevere ? Colors.white : scoreColor, + ), + ), + Text( + '/42', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w600, + color: isExtremelySevere ? Colors.white70 : AppColors.textLight, + ), + ), + ], + ), + const SizedBox(height: 2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: isExtremelySevere + ? Colors.white.withValues(alpha: 0.2) + : scoreColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + severityIndo, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 8.5, + fontWeight: FontWeight.w800, + color: isExtremelySevere ? Colors.white : scoreColor, + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _showDassInfoDialog(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Icon(Icons.info_outline, color: AppColors.primary, size: 22), + const SizedBox(width: 8), + Text( + 'Panduan Skala DASS-21', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'DASS-21 (Depression Anxiety Stress Scales) mengukur 3 indikator psikologis harian (0–42 poin per subskala) dari transkrip percakapanmu bersama Eva AI.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.45, + ), + ), + const SizedBox(height: 16), + Text( + 'KATEGORI WARNA & TINGKAT KEPARAHAN:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + _buildLegendRow('Normal', 'Kondisi emosi stabil & terkendali.', const Color(0xFF2E7D32)), + _buildLegendRow('Ringan (Mild)', 'Fluktuasi emosi ringan harian.', const Color(0xFFFBC02D)), + _buildLegendRow('Sedang (Moderate)', 'Mulai mengganggu kenyamanan & butuh jeda istirahat.', const Color(0xFFF57C00)), + _buildLegendRow('Berat (Severe)', 'Beban emosional terasa berat & perlu perhatian khusus.', const Color(0xFFE65100)), + _buildLegendRow('Sangat Berat (Extremely Severe)', 'Indikasi krisis — sangat disarankan konsultasi ke psikolog/psikiater.', const Color(0xFFD32F2F)), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Saya Mengerti'), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildLegendRow(String label, String desc, Color color) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text( + label, + style: GoogleFonts.inter(fontSize: 12.5, fontWeight: FontWeight.w700, color: color), + ), + ], + ), + const SizedBox(height: 3), + Padding( + padding: const EdgeInsets.only(left: 18), + child: Text( + desc, + style: GoogleFonts.inter( + fontSize: 11.5, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ), + ], + ), + ); + } + + void _showSubscaleInfoModal( + BuildContext context, + String label, + int score, + String severityIndo, + String scaleType, + ) { + final scoreColor = _getScoreColor(severityIndo); + String definition = ''; + String tip = ''; + + if (scaleType == 'depression') { + definition = + 'Subskala Depresi mengukur tingkat suasana hati disforik, hilangnya motivasi/inisiatif, anhedonia (kesulitan merasakan hal positif), dan perasaan ketidakberhargaan.'; + tip = + 'Coba lakukan aktivitas kecil yang kamu sukai, luapkan pikiran melalui percakapan bersama EVA, atau jalan santai menghirup udara segar.'; + } else if (scaleType == 'anxiety') { + definition = + 'Subskala Kecemasan mengukur stimulasi otonomik (otot tegang, detak jantung kencang), cemas situasional, dan ketakutan akan kehilangan kendali.'; + tip = + 'Lakukan teknik pernapasan 4-7-8 atau relaksasi otot bertahap untuk menurunkan stimulasi fisik berlebih.'; + } else { + definition = + 'Subskala Stres mengukur ketegangan syaraf non-spesifik, reaksi berlebihan pada masalah harian, kegelisahan, dan ketidakmampuan untuk bersantai.'; + tip = + 'Ambil jeda istirahat dari pekerjaan/tugas, hindari multitasking berlebih, dan nikmati minuman hangat.'; + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: scoreColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: scoreColor, + ), + ), + ), + const Spacer(), + Text( + '$score / 42 Poin', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Text( + 'Tingkat Keparahan: ', + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textSecondary), + ), + Text( + severityIndo, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: scoreColor, + ), + ), + ], + ), + const SizedBox(height: 14), + const Divider(height: 1, color: Color(0xFFEADBFF)), + const SizedBox(height: 14), + Text( + 'PENJELASAN INDIKATOR:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + Text( + definition, + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textPrimary, height: 1.45), + ), + const SizedBox(height: 14), + Text( + 'REKOMENDASI MANDIRI:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Text( + tip, + style: GoogleFonts.inter(fontSize: 12.5, color: AppColors.textPrimary, height: 1.4), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Tutup'), + ), + ), + ], + ), + ), + ); + } + + void _showPsychologistReferralModal( + BuildContext context, + String label, + int score, + String scaleType, + ) { + String detailExplanation = ''; + + if (scaleType == 'depression') { + detailExplanation = + 'Sistem mencatat tingkat Depresi pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini dicirikan oleh anhedonia total (kehilangan kemampuan merasakan hal positif), perasaan tidak berharga mendalam, serta keletihan fisik dan emosional yang signifikan.\n\n' + '⚠️ Catatan Penting: Eva AI adalah pendamping mandiri dan bukan pengganti diagnosis atau terapi medis klinis. Kamu sangat disarankan untuk berbicara langsung dengan Psikolog Klinis atau Psikiater.'; + } else if (scaleType == 'anxiety') { + detailExplanation = + 'Sistem mencatat tingkat Kecemasan pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini ditandai dengan serangan panik intens, detak jantung kencang tanpa alasan fisik, guncangan rasa takut akut, atau kekhawatiran melumpuhkan harian.\n\n' + '⚠️ Catatan Penting: Gejala kecemasan akut membutuhkan penanganan profesional medis. Sangat disarankan untuk segera menemui Psikolog Klinis atau Psikiater terdekat.'; + } else { + detailExplanation = + 'Sistem mencatat tingkat Stres pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini mengindikasikan burnout ekstrem, ketegangan saraf kronis, kelelahan energi berat, serta keputusasaan dalam menangani tekanan harian.\n\n' + '⚠️ Catatan Penting: Beban stres kronis dapat berdampak pada kesehatan fisik. Disarankan untuk mengambil jeda penuh dan melakukan sesi konseling bersama psikolog profesional.'; + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFEBEE), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Color(0xFFD32F2F), size: 28), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SANGAT BERAT (EXTREMELY SEVERE)', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFFD32F2F), + letterSpacing: 0.5, + ), + ), + Text( + 'Indikasi $label: $score / 42 Poin', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: const Color(0xFFB71C1C), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + 'PENJELASAN KLINIS & REKOMENDASI:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Text( + detailExplanation, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.5, + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(ctx); + _openWhatsappEmergency(context); + }, + icon: const Icon(Icons.chat_outlined, size: 20), + label: Text( + 'Konsultasi via WhatsApp Psikolog / Krisis', + style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF25D366), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text( + 'Tutup Penjelasan', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ), + ], + ), + ), + ); + } + + + static const String _whatsappEmergencyUrl = + 'https://api.whatsapp.com/send/?phone=6281380073120&text=halo%20kak%2C%20saya%20ingin%20bercerita%20mengenai...&type=phone_number&app_absent=0'; + + Future<void> _openWhatsappEmergency(BuildContext context) async { + final uri = Uri.parse(_whatsappEmergencyUrl); + try { + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched) { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } + } catch (_) { + try { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } catch (_) { + if (context.mounted) { + AppSnackBar.showError( + context, + 'Tidak dapat membuka WhatsApp Konseling Krisis', + ); + } + } + } + } + +} diff --git a/apps/eva_mobile/lib/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart new file mode 100644 index 0000000..8869fcc --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart @@ -0,0 +1,77 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +/// Custom Painter for 7-Emotion Vertical Stacked Bar Chart +class StackedEmotionChartPainter extends CustomPainter { + final List<List<double>> chartData; + final List<Color> emotionColors; + + StackedEmotionChartPainter({ + required this.chartData, + required this.emotionColors, + }); + + @override + void paint(Canvas canvas, Size size) { + if (chartData.isEmpty) return; + + final int barCount = chartData.length; + final double availableWidth = size.width; + const double maxBarWidth = 28.0; + final double barWidth = min(maxBarWidth, (availableWidth / barCount) * 0.45); + + for (int i = 0; i < barCount; i++) { + final segments = chartData[i]; + final double centerX = (i + 0.5) * (availableWidth / barCount); + final double barLeft = centerX - (barWidth / 2); + double currentBottom = size.height; + + final double totalRatio = segments.fold(0.0, (sum, val) => sum + val); + if (totalRatio <= 0) { + // Draw subtle baseline indicator for empty bar slot + final baseRect = Rect.fromLTRB(barLeft, size.height - 3, barLeft + barWidth, size.height); + final basePaint = Paint() + ..color = const Color(0xFFD8DCE8) + ..style = PaintingStyle.fill; + canvas.drawRRect(RRect.fromRectAndRadius(baseRect, const Radius.circular(2)), basePaint); + continue; + } + + // Top position of the bar (leaving 4dp headroom) + const double barTop = 4.0; + final totalBarRect = Rect.fromLTRB(barLeft, barTop, barLeft + barWidth, size.height); + final double cornerRadius = min(6.0, barWidth / 2); + final totalBarRRect = RRect.fromRectAndRadius( + totalBarRect, + Radius.circular(cornerRadius), + ); + + // Clip canvas to smoothly round all 4 outer corners of the bar uniformly + canvas.save(); + canvas.clipRRect(totalBarRRect); + + for (int j = 0; j < segments.length; j++) { + final double ratio = segments[j]; + if (ratio <= 0) continue; + + final double segHeight = (size.height - barTop) * (ratio / totalRatio); + final double top = currentBottom - segHeight; + + final rect = Rect.fromLTRB(barLeft, top, barLeft + barWidth, currentBottom); + final paint = Paint() + ..color = emotionColors[j % emotionColors.length] + ..style = PaintingStyle.fill; + + canvas.drawRect(rect, paint); + currentBottom = top; + } + + canvas.restore(); + } + } + + @override + bool shouldRepaint(covariant StackedEmotionChartPainter oldDelegate) { + return oldDelegate.chartData != chartData; + } +} diff --git a/apps/eva_mobile/lib/features/recommendations/data/datasources/mock_recommendation_datasource.dart b/apps/eva_mobile/lib/features/recommendations/data/datasources/mock_recommendation_datasource.dart index 20b6c3f..c02bf0d 100644 --- a/apps/eva_mobile/lib/features/recommendations/data/datasources/mock_recommendation_datasource.dart +++ b/apps/eva_mobile/lib/features/recommendations/data/datasources/mock_recommendation_datasource.dart @@ -43,6 +43,55 @@ class MockRecommendationDataSource implements RecommendationDataSource { return List.unmodifiable(_items); } + @override + Future<Map<String, dynamic>> getTodayRecommendation() async { + await Future.delayed(const Duration(milliseconds: 200)); + return { + 'conditionLabel': 'Fokus: Perawatan Diri & Relaksasi', + 'conditionReason': 'Tingkat stres stabil. Pertahankan rutinitas meditasi.', + 'severityLevel': 'Normal', + 'isCrisis': false, + 'todayActivity': { + 'id': 'c1', + 'title': 'Latihan Pernapasan 4-7-8', + 'category': 'Mindfulness', + 'targetCondition': 'stress', + 'duration': '3-5 Menit', + 'difficulty': 'Pemula', + 'description': 'Tarik napas selama 4 hitungan, tahan 7 hitungan, buang perlahan selama 8 hitungan untuk menenangkan sistem saraf parasimpatis.', + }, + 'allRecommendations': [ + { + 'id': 'c1', + 'title': 'Latihan Pernapasan 4-7-8', + 'category': 'Mindfulness', + 'targetCondition': 'stress', + 'duration': '3-5 Menit', + 'difficulty': 'Pemula', + 'description': 'Tarik napas selama 4 hitungan, tahan 7 hitungan, buang perlahan selama 8 hitungan.', + }, + { + 'id': 'c2', + 'title': 'Jurnal Rasa Syukur 3 Poin', + 'category': 'Refleksi', + 'targetCondition': 'general', + 'duration': '5 Menit', + 'difficulty': 'Mudah', + 'description': 'Tuliskan 3 hal kecil yang kamu syukuri hari ini.', + }, + { + 'id': 'c3', + 'title': 'Peregangan Leher & Bahu', + 'category': 'Fisik', + 'targetCondition': 'stress', + 'duration': '3 Menit', + 'difficulty': 'Mudah', + 'description': 'Gerakkan leher ke kiri, kanan, atas, dan bawah secara perlahan.', + }, + ], + }; + } + @override Future<void> markAsCompleted(String id) async { await Future.delayed(const Duration(milliseconds: 200)); diff --git a/apps/eva_mobile/lib/features/recommendations/data/datasources/recommendation_datasource.dart b/apps/eva_mobile/lib/features/recommendations/data/datasources/recommendation_datasource.dart index 8aad18a..95fad65 100644 --- a/apps/eva_mobile/lib/features/recommendations/data/datasources/recommendation_datasource.dart +++ b/apps/eva_mobile/lib/features/recommendations/data/datasources/recommendation_datasource.dart @@ -2,5 +2,6 @@ import '../models/recommendation_model.dart'; abstract class RecommendationDataSource { Future<List<RecommendationModel>> getRecommendations(); + Future<Map<String, dynamic>> getTodayRecommendation(); Future<void> markAsCompleted(String id); } diff --git a/apps/eva_mobile/lib/features/recommendations/data/datasources/remote_recommendation_datasource.dart b/apps/eva_mobile/lib/features/recommendations/data/datasources/remote_recommendation_datasource.dart index 9d1506c..ac83728 100644 --- a/apps/eva_mobile/lib/features/recommendations/data/datasources/remote_recommendation_datasource.dart +++ b/apps/eva_mobile/lib/features/recommendations/data/datasources/remote_recommendation_datasource.dart @@ -14,6 +14,15 @@ class RemoteRecommendationDataSource implements RecommendationDataSource { return list.map((e) => RecommendationModel.fromJson(e)).toList(); } + @override + Future<Map<String, dynamic>> getTodayRecommendation() async { + final response = await _apiClient.get('/recommendations/today'); + if (response is Map<String, dynamic>) { + return response; + } + return {}; + } + @override Future<void> markAsCompleted(String id) async { await _apiClient.post('/recommendations/$id/complete'); diff --git a/apps/eva_mobile/lib/features/recommendations/data/repositories/fallback_recommendation_repository.dart b/apps/eva_mobile/lib/features/recommendations/data/repositories/fallback_recommendation_repository.dart new file mode 100644 index 0000000..6917272 --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/data/repositories/fallback_recommendation_repository.dart @@ -0,0 +1,41 @@ +import '../../domain/repositories/recommendation_repository.dart'; +import '../../domain/entities/recommendation_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackRecommendationRepository implements RecommendationRepository { + final RecommendationRepository _remote; + final RecommendationRepository _mock; + + FallbackRecommendationRepository({ + required RecommendationRepository remote, + required RecommendationRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future<List<RecommendationEntity>> getRecommendations() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getRecommendations(), + mockCall: () => _mock.getRecommendations(), + operationName: 'getRecommendations', + ); + } + + @override + Future<Map<String, dynamic>> getTodayRecommendation() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getTodayRecommendation(), + mockCall: () => _mock.getTodayRecommendation(), + operationName: 'getTodayRecommendation', + ); + } + + @override + Future<void> markAsCompleted(String id) { + return FallbackExecutor.execute( + remoteCall: () => _remote.markAsCompleted(id), + mockCall: () => _mock.markAsCompleted(id), + operationName: 'markAsCompleted($id)', + ); + } +} diff --git a/apps/eva_mobile/lib/features/recommendations/data/repositories/recommendation_repository_impl.dart b/apps/eva_mobile/lib/features/recommendations/data/repositories/recommendation_repository_impl.dart index 842218a..3acbcae 100644 --- a/apps/eva_mobile/lib/features/recommendations/data/repositories/recommendation_repository_impl.dart +++ b/apps/eva_mobile/lib/features/recommendations/data/repositories/recommendation_repository_impl.dart @@ -10,6 +10,9 @@ class RecommendationRepositoryImpl implements RecommendationRepository { @override Future<List<RecommendationEntity>> getRecommendations() => _dataSource.getRecommendations(); + @override + Future<Map<String, dynamic>> getTodayRecommendation() => _dataSource.getTodayRecommendation(); + @override Future<void> markAsCompleted(String id) => _dataSource.markAsCompleted(id); } diff --git a/apps/eva_mobile/lib/features/recommendations/domain/repositories/recommendation_repository.dart b/apps/eva_mobile/lib/features/recommendations/domain/repositories/recommendation_repository.dart index 6904d2a..f1dd6aa 100644 --- a/apps/eva_mobile/lib/features/recommendations/domain/repositories/recommendation_repository.dart +++ b/apps/eva_mobile/lib/features/recommendations/domain/repositories/recommendation_repository.dart @@ -2,5 +2,6 @@ import '../entities/recommendation_entity.dart'; abstract class RecommendationRepository { Future<List<RecommendationEntity>> getRecommendations(); + Future<Map<String, dynamic>> getTodayRecommendation(); Future<void> markAsCompleted(String id); } diff --git a/apps/eva_mobile/lib/features/recommendations/presentation/providers/recommendation_provider.dart b/apps/eva_mobile/lib/features/recommendations/presentation/providers/recommendation_provider.dart index 5ff35d3..55d173e 100644 --- a/apps/eva_mobile/lib/features/recommendations/presentation/providers/recommendation_provider.dart +++ b/apps/eva_mobile/lib/features/recommendations/presentation/providers/recommendation_provider.dart @@ -1,16 +1,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/recommendation_repository.dart'; import '../../domain/entities/recommendation_entity.dart'; import '../../data/datasources/mock_recommendation_datasource.dart'; import '../../data/datasources/remote_recommendation_datasource.dart'; import '../../data/repositories/recommendation_repository_impl.dart'; +import '../../data/repositories/fallback_recommendation_repository.dart'; final recommendationRepositoryProvider = Provider<RecommendationRepository>((ref) { + final mockRepo = RecommendationRepositoryImpl(MockRecommendationDataSource()); + final remoteRepo = RecommendationRepositoryImpl(RemoteRecommendationDataSource()); + if (AppConfig.useMockData) { - return RecommendationRepositoryImpl(MockRecommendationDataSource()); + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackRecommendationRepository(remote: remoteRepo, mock: mockRepo); } else { - return RecommendationRepositoryImpl(RemoteRecommendationDataSource()); + return remoteRepo; } }); diff --git a/apps/eva_mobile/lib/features/recommendations/presentation/screens/recommendation_screen.dart b/apps/eva_mobile/lib/features/recommendations/presentation/screens/recommendation_screen.dart new file mode 100644 index 0000000..a32604f --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/presentation/screens/recommendation_screen.dart @@ -0,0 +1,1229 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/recommendations/presentation/providers/recommendation_provider.dart'; +import 'package:eva_mobile/features/recommendations/presentation/widgets/guidance_modal_content.dart'; +import 'package:eva_mobile/features/recommendations/presentation/widgets/recommendation_skeleton_loader.dart'; +import 'package:eva_mobile/services/daily_progress_local_service.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class RecommendationScreen extends ConsumerStatefulWidget { + const RecommendationScreen({super.key}); + + @override + ConsumerState<RecommendationScreen> createState() => _RecommendationScreenState(); +} + +class _RecommendationScreenState extends ConsumerState<RecommendationScreen> { + static final List<Map<String, dynamic>> _defaultRecommendations = [ + { + 'id': 'c1', + 'title': 'Latihan Pernapasan 4-7-8', + 'category': 'Mindfulness', + 'targetCondition': 'stress', + 'duration': '3-5 Menit', + 'difficulty': 'Pemula', + 'description': + 'Tarik napas dalam 4 detik, tahan 7 detik, lalu hembuskan perlahan 8 detik untuk meredakan ketegangan sistem saraf.', + 'instructions': [ + 'Duduk dengan posisi santai dan tegakkan punggung.', + 'Tarik napas perlahan lewat hidung selama 4 detik.', + 'Tahan napas di rongga dada selama 7 detik.', + 'Hembuskan napas perlahan lewat mulut selama 8 detik hingga dada terasa rileks.', + 'Ulangi siklus ini sebanyak 4 hingga 5 kali.' + ], + 'rationale': + 'Mengaktifkan saraf parasimpatik (vagus nerve) untuk menurunkan denyut jantung dan meredakan ketegangan fisiologis.', + 'iconName': 'air', + 'isCompleted': false, + }, + { + 'id': 'c2', + 'title': 'Teknik Grounding 5-4-3-2-1', + 'category': 'CBT', + 'targetCondition': 'anxiety', + 'duration': '5 Menit', + 'difficulty': 'Pemula', + 'description': + 'Sadarilah 5 hal yang dilihat, 4 yang disentuh, 3 suara, 2 aroma, dan 1 rasa untuk meredakan overthinking.', + 'instructions': [ + 'Lihat ke sekitarmu: Sebutkan 5 benda yang dapat kamu lihat saat ini.', + 'Sentuh benda di dekatmu: Rasakan 4 tekstur berbeda (pakaian, meja, jari tangan).', + 'Dengarkan secara saksama: Sadari 3 suara di sekitarmu (kipas, kendaraan, napas).', + 'Hirup udara: Sadari 2 aroma di sekitarmu.', + 'Rasakan mulutmu: Sadari 1 rasa di lidahmu saat ini.' + ], + 'rationale': + 'Mengalihkan fokus otak dari overthinking dan badai cemas kembali ke realitas fisik saat ini.', + 'iconName': 'psychology', + 'isCompleted': false, + }, + { + 'id': 'c3', + 'title': 'Catatan Syukur Harian', + 'category': 'Refleksi', + 'targetCondition': 'depression', + 'duration': '3 Menit', + 'difficulty': 'Pemula', + 'description': + 'Tuliskan hal-hal sederhana yang memberi kehangatan atau rasa nyaman hari ini.', + 'instructions': [ + 'Ambil buku catatan kecil atau gunakan catatan di ponselmu.', + 'Pikirkan 1 hingga 3 hal kecil yang terjadi hari ini (misal: secangkir teh hangat, udara sejuk).', + 'Tuliskan secara singkat mengapa hal itu terasa bermakna bagimu.', + 'Rasakan sensasi lega dan hangat di dalam hatimu sejenak.' + ], + 'rationale': + 'Mengimbangi bias negatif kognitif pada suasana hati murung dengan melatih fokus pada hal positif.', + 'iconName': 'edit_note', + 'isCompleted': false, + }, + { + 'id': 'c9', + 'title': 'Metode Notice and Name', + 'category': 'CBT', + 'targetCondition': 'anxiety', + 'duration': '4 Menit', + 'difficulty': 'Menengah', + 'description': + 'Lepaskan diri dari pikiran cemas yang menjebak dengan mengenali dan menamainya.', + 'instructions': [ + 'Ketika pikiran cemas muncul, berhenti sejenak tanpa menghakiminya.', + 'Katakan pada diri sendiri: "Aku sedang menyadari pikiran bahwa..."', + 'Ingat bahwa pikiran hanyalah kata-kata atau gambaran mental, bukan fakta pasti.', + 'Bernapas perlahan dan biarkan pikiran tersebut berlalu seperti awan di langit.' + ], + 'rationale': + 'Membantu defusi kognitif agar kamu tidak larut dan terseret oleh lingkaran pikiran cemas.', + 'iconName': 'psychology', + 'isCompleted': false, + }, + { + 'id': 'c10', + 'title': 'Metode Stop-Think-Go', + 'category': 'CBT', + 'targetCondition': 'stress', + 'duration': '5 Menit', + 'difficulty': 'Menengah', + 'description': + 'Teknik pemecahan masalah bertahap saat pikiran terasa menumpuk dan membebani.', + 'instructions': [ + 'STOP: Berhenti sejenak dari aktivitasmu, letakkan ponsel, dan ambil satu napas panjang.', + 'THINK: Uraikan apa yang sebenarnya sedang terjadi dan apa yang bisa kamu kendalikan saat ini.', + 'GO: Pilih 1 tindakan terkecil yang bisa kamu lakukan sekarang tanpa perlu menyelesaikan semuanya sekaligus.' + ], + 'rationale': + 'Mencegah respon panik atau impulsif ketika merasa kewalahan dengan memecah masalah menjadi langkah mikro.', + 'iconName': 'psychology', + 'isCompleted': false, + }, + { + 'id': 'c4', + 'title': 'Jalan Santai Mindful', + 'category': 'Aktivitas Fisik', + 'targetCondition': 'general', + 'duration': '10 Menit', + 'difficulty': 'Pemula', + 'description': + 'Berjalan santai di ruangan atau luar ruangan sambil memperhatikan sensasi langkah kaki.', + 'instructions': [ + 'Berdiri tegak, rilekskan bahu dan rahangmu.', + 'Mulai melangkah dengan ritme santai dan perlahan.', + 'Fokuskan perhatian pada telapak kakimu yang menyentuh tanah pada setiap langkah.', + 'Jika pikiranmu melayang, dengan lembut bawa kembali perhatianmu pada langkah kaki.' + ], + 'rationale': + 'Menggabungkan gerakan fisik ringan dengan perhatian sadar untuk memulihkan energi dan kejernihan pikiran.', + 'iconName': 'directions_walk', + 'isCompleted': false, + }, + ]; + + String _conditionLabel = 'Fokus: Relaksasi & Perawatan Diri'; + String _conditionReason = + 'Berikut beberapa panduan terstruktur untuk membantumu meredakan ketegangan dan menjaga ketenangan hari ini.'; + String _severityLevel = 'Normal'; + bool _isCrisis = false; + String _crisisHotline = 'Kemenkes 119 ext 8 / LISA 0811-3855-472'; + String _crisisGuidance = + 'Keselamatan dan ketenanganmu adalah prioritas paling berharga. Silakan hubungi bantuan darurat bila beban terasa terlalu berat.'; + + Map<String, dynamic>? _todayActivity; + List<Map<String, dynamic>> _recommendations = []; + String _selectedCategory = 'Semua'; + bool _isLoading = false; + + final List<String> _categories = [ + 'Semua', + 'Mindfulness', + 'CBT', + 'Refleksi', + 'Fisik & Gerak', + ]; + + @override + void initState() { + super.initState(); + _fetchRecommendations(); + } + + Future<void> _fetchRecommendations() async { + final localProgress = await DailyProgressLocalService.loadTodayProgress(); + + setState(() => _isLoading = true); + try { + final repo = ref.read(recommendationRepositoryProvider); + final data = await repo.getTodayRecommendation(); + + if (!mounted) return; + + if (data.isNotEmpty) { + final rawToday = data['todayActivity'] as Map<String, dynamic>?; + final rawAll = (data['allRecommendations'] as List<dynamic>?) ?? + (data['alternatives'] as List<dynamic>?) ?? + []; + + Map<String, dynamic>? mappedToday; + if (rawToday != null) { + final tId = rawToday['id']?.toString() ?? ''; + mappedToday = _normalizeActivity(rawToday, localProgress.isActivityCompleted(tId)); + } + + final List<Map<String, dynamic>> mappedList = []; + if (mappedToday != null) { + mappedList.add(mappedToday); + } + + for (var item in rawAll) { + if (item is Map<String, dynamic>) { + final aId = item['id']?.toString() ?? ''; + if (mappedToday != null && aId == mappedToday['id']) continue; + mappedList.add(_normalizeActivity(item, localProgress.isActivityCompleted(aId))); + } + } + + setState(() { + _conditionLabel = data['conditionLabel']?.toString() ?? 'Fokus: Perawatan Diri'; + _conditionReason = data['conditionReason']?.toString() ?? ''; + _severityLevel = data['severityLevel']?.toString() ?? 'Normal'; + _isCrisis = data['isCrisis'] == true; + _crisisHotline = data['crisisHotline']?.toString() ?? _crisisHotline; + _crisisGuidance = data['crisisGuidance']?.toString() ?? _crisisGuidance; + _todayActivity = mappedToday ?? (mappedList.isNotEmpty ? mappedList.first : null); + _recommendations = mappedList.isNotEmpty ? mappedList : _defaultRecommendations; + }); + } else { + _useFallbackData(localProgress); + } + } catch (_) { + _useFallbackData(localProgress); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + void _useFallbackData(DailyProgressData localProgress) { + if (!mounted) return; + setState(() { + _recommendations = _defaultRecommendations.map((r) { + final copy = Map<String, dynamic>.from(r); + final rId = copy['id']?.toString() ?? ''; + copy['isCompleted'] = localProgress.isActivityCompleted(rId); + return copy; + }).toList(); + _todayActivity = _recommendations.firstWhere( + (r) => r['isCompleted'] != true, + orElse: () => _recommendations.first, + ); + }); + } + + Map<String, dynamic> _normalizeActivity(Map<String, dynamic> raw, bool isDone) { + final actId = raw['id']?.toString() ?? ''; + final instructionsRaw = raw['instructions']; + List<String> instructions = []; + if (instructionsRaw is List) { + instructions = instructionsRaw.map((e) => e.toString()).toList(); + } + + return { + 'id': actId, + 'title': raw['title']?.toString() ?? 'Latihan Relaksasi', + 'category': raw['category']?.toString() ?? 'Mindfulness', + 'targetCondition': raw['targetCondition']?.toString() ?? 'general', + 'duration': raw['duration']?.toString() ?? '5 Menit', + 'difficulty': raw['difficulty']?.toString() ?? raw['level']?.toString() ?? 'Pemula', + 'description': raw['description']?.toString() ?? '', + 'instructions': instructions, + 'rationale': raw['rationale']?.toString() ?? '', + 'iconName': raw['iconName']?.toString() ?? 'spa', + 'isCompleted': raw['isCompleted'] == true || isDone, + }; + } + + Future<void> _completeRecommendation(String id) async { + final idx = _recommendations.indexWhere((r) => r['id'] == id); + final bool alreadyDone = (idx != -1 && _recommendations[idx]['isCompleted'] == true) || + (_todayActivity != null && _todayActivity!['id'] == id && _todayActivity!['isCompleted'] == true); + + if (alreadyDone) { + if (mounted) { + AppSnackBar.showPrimary( + context, + 'Kamu sudah menyelesaikan latihan ini hari ini! Kerja bagus, istirahatlah sejenak dan lanjutkan kembali besok 🌿', + icon: Icons.info_outline, + ); + } + return; + } + + setState(() { + if (idx != -1) { + _recommendations[idx]['isCompleted'] = true; + } + if (_todayActivity != null && _todayActivity!['id'] == id) { + _todayActivity!['isCompleted'] = true; + } + }); + + await DailyProgressLocalService.recordActivityCompletion(id, true); + + if (mounted) { + AppSnackBar.showSuccess( + context, + 'Bagus sekali! Satu langkah kecil untuk ketenanganmu telah selesai.', + icon: Icons.check_circle, + ); + } + + try { + await ref.read(recommendationRepositoryProvider).markAsCompleted(id); + } catch (_) {} + } + + IconData _getIconForActivity(String? iconName, String? category) { + if (iconName != null) { + switch (iconName.toLowerCase()) { + case 'air': + return Icons.air; + case 'spa': + return Icons.spa; + case 'psychology': + return Icons.psychology; + case 'edit_note': + return Icons.edit_note; + case 'directions_walk': + return Icons.directions_walk; + case 'emergency': + return Icons.emergency; + case 'task_alt': + return Icons.task_alt; + case 'phonelink_off': + return Icons.phonelink_off; + case 'self_improvement': + return Icons.self_improvement; + } + } + final cat = (category ?? '').toLowerCase(); + if (cat.contains('mindful') || cat.contains('napas')) return Icons.air; + if (cat.contains('refleksi') || cat.contains('self-care') || cat.contains('syukur')) return Icons.edit_note; + if (cat.contains('cbt') || cat.contains('problem') || cat.contains('kognitif')) return Icons.psychology; + if (cat.contains('somatis') || cat.contains('fisik') || cat.contains('aktivasi') || cat.contains('jalan')) return Icons.directions_walk; + if (cat.contains('krisis') || cat.contains('darurat')) return Icons.emergency; + return Icons.self_improvement; + } + + Color _getCategoryColor(String? category) { + final cat = (category ?? '').toLowerCase(); + if (cat.contains('mindful') || cat.contains('napas')) return const Color(0xFF6366F1); + if (cat.contains('refleksi') || cat.contains('self-care') || cat.contains('syukur')) return const Color(0xFF8B5CF6); + if (cat.contains('cbt') || cat.contains('problem') || cat.contains('kognitif')) return const Color(0xFF0EA5E9); + if (cat.contains('somatis') || cat.contains('fisik') || cat.contains('aktivasi') || cat.contains('jalan')) return const Color(0xFF10B981); + if (cat.contains('krisis') || cat.contains('darurat')) return const Color(0xFFEF4444); + return AppColors.primary; + } + + Color _getSeverityColor(String severity) { + switch (severity.toLowerCase()) { + case 'berat': + case 'sangat berat': + case 'krisis / darurat': + return const Color(0xFFEF4444); + case 'sedang': + return const Color(0xFFF59E0B); + case 'ringan': + return const Color(0xFF10B981); + default: + return const Color(0xFF6366F1); + } + } + + bool _matchesCategory(String? category, String selected) { + if (selected == 'Semua') return true; + final cat = (category ?? '').toLowerCase(); + switch (selected) { + case 'Mindfulness': + return cat.contains('mindful') || cat.contains('napas') || cat.contains('breath'); + case 'CBT': + return cat.contains('cbt') || cat.contains('kognitif') || cat.contains('problem') || cat.contains('unhooking'); + case 'Refleksi': + return cat.contains('refleksi') || cat.contains('syukur') || cat.contains('self-care') || cat.contains('catatan'); + case 'Fisik & Gerak': + return cat.contains('fisik') || cat.contains('gerak') || cat.contains('somatis') || cat.contains('aktivasi') || cat.contains('jalan'); + default: + return cat.contains(selected.toLowerCase()); + } + } + + int _getCategoryCount(String category) { + if (category == 'Semua') return _recommendations.length; + return _recommendations.where((r) => _matchesCategory(r['category']?.toString(), category)).length; + } + + @override + Widget build(BuildContext context) { + final filteredRecommendations = _selectedCategory == 'Semua' + ? _recommendations + : _recommendations + .where((r) => _matchesCategory(r['category']?.toString(), _selectedCategory)) + .toList(); + + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF7F9FD), + Color(0xFFEFF3FD), + Color(0xFFF7F8FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Column( + children: [ + StaggeredEntrance( + index: 0, + child: _buildTopBar(), + ), + Expanded( + child: _isLoading + ? const RecommendationSkeletonLoader() + : RefreshIndicator( + onRefresh: _fetchRecommendations, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20.0, 8.0, 20.0, 80.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Crisis Emergency Banner (if applicable) + if (_isCrisis) ...[ + _buildCrisisBanner(), + const SizedBox(height: 18), + ], + + // 2. Clinically Tailored Condition Header Card + StaggeredEntrance( + index: 1, + child: _buildConditionContextCard(), + ), + const SizedBox(height: 20), + + // 3. Featured Activity of the Day + if (_todayActivity != null) ...[ + StaggeredEntrance( + index: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Aktivitas Utama Hari Ini', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Langkah kecil paling relevan untuk menenangkan pikiranmu saat ini.', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 10), + _buildFeaturedTodayCard(_todayActivity!), + ], + ), + ), + const SizedBox(height: 24), + ], + + // 4. Category Filter Chips + StaggeredEntrance( + index: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pilihan Latihan Ketenangan', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Pilih teknik yang paling nyaman dan sesuai kebutuhanmu.', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + _buildCategorySelector(), + ], + ), + ), + const SizedBox(height: 14), + + // 5. List of Recommended Activities + if (filteredRecommendations.isEmpty) + _buildEmptyState() + else + StaggeredEntrance( + index: 4, + child: Column( + children: [ + for (final rec in filteredRecommendations) + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: _buildActivityCard(rec), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTopBar() { + final canPop = Navigator.canPop(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0), + child: Row( + children: [ + if (canPop) ...[ + const AppBackButton(), + const SizedBox(width: 12), + ] else ...[ + Image.asset( + 'assets/images/eva_logo.png', + width: 30, + height: 30, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => const Icon( + Icons.nightlight_round, + size: 26, + color: AppColors.primary, + ), + ), + const SizedBox(width: 8), + ], + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Panduan Ketenangan', + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Latihan & Pemulihan Mandiri', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + const Spacer(), + IconButton( + tooltip: 'Perbarui Panduan', + icon: const Icon(Icons.refresh, size: 22), + color: AppColors.primary, + onPressed: _fetchRecommendations, + ), + ], + ), + ); + } + + Widget _buildCrisisBanner() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFFECACA), width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.red.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Color(0xFFFEE2E2), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.favorite, + color: Color(0xFFDC2626), + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Kamu Berharga & Tidak Sendirian', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: const Color(0xFF991B1B), + ), + ), + Text( + 'Prioritas Utama: Keselamatan & Bantuan Nyata', + style: GoogleFonts.inter( + fontSize: 11, + color: const Color(0xFFB91C1C), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + _crisisGuidance, + style: GoogleFonts.inter( + fontSize: 12, + height: 1.4, + color: const Color(0xFF7F1D1D), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + Navigator.pushNamed(context, '/support'); + }, + icon: const Icon(Icons.support_agent, size: 18, color: Colors.white), + label: Text( + 'Pusat Bantuan & Hotline', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFDC2626), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildConditionContextCard() { + final sevColor = _getSeverityColor(_severityLevel); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5358CB).withValues(alpha: 0.06), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.insights_rounded, + size: 13, + color: AppColors.primary, + ), + const SizedBox(width: 5), + Flexible( + child: Text( + _conditionLabel.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 0.4, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: sevColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: sevColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + 'Level: $_severityLevel', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: sevColor, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + _conditionReason.isNotEmpty + ? _conditionReason + : 'Panduan ini dipilih agar kamu tahu apa yang harus dilakukan tanpa bingung, langkah demi langkah.', + style: GoogleFonts.inter( + fontSize: 12, + height: 1.45, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildFeaturedTodayCard(Map<String, dynamic> act) { + final isDone = act['isCompleted'] == true; + final title = act['title']?.toString() ?? 'Latihan Relaksasi'; + final desc = act['description']?.toString() ?? ''; + final duration = act['duration']?.toString() ?? '5 Menit'; + final category = act['category']?.toString() ?? 'Mindfulness'; + final difficulty = act['difficulty']?.toString() ?? 'Pemula'; + final rationale = act['rationale']?.toString() ?? ''; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + gradient: const LinearGradient( + colors: [ + Color(0xFF4F46E5), + Color(0xFF6366F1), + Color(0xFF818CF8), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF4F46E5).withValues(alpha: 0.28), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + category.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 0.6, + ), + ), + ), + Row( + children: [ + const Icon(Icons.schedule, size: 12, color: Colors.white70), + const SizedBox(width: 4), + Text( + '$duration • $difficulty', + style: GoogleFonts.inter( + fontSize: 11, + color: Colors.white70, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Text( + title, + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + Text( + desc, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + height: 1.4, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + if (rationale.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.lightbulb, size: 13, color: Colors.amberAccent), + const SizedBox(width: 6), + Expanded( + child: Text( + rationale, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => _showGuidanceModal(context, act), + icon: const Icon(Icons.play_circle_fill, size: 18, color: Color(0xFF4F46E5)), + label: Text( + 'Buka Panduan Langkah', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF4F46E5), + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 11), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: () => _completeRecommendation(act['id'].toString()), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isDone + ? const Color(0xFF10B981) + : Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDone + ? const Color(0xFF10B981) + : Colors.white.withValues(alpha: 0.4), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isDone ? Icons.check_circle : Icons.check_circle_outline, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 6), + Text( + isDone ? 'Selesai' : 'Tandai', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildCategorySelector() { + return SizedBox( + height: 36, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _categories.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final cat = _categories[index]; + final isSelected = cat == _selectedCategory; + final count = _getCategoryCount(cat); + return GestureDetector( + onTap: () => setState(() => _selectedCategory = cat), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFE2E8F0), + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.25), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + cat, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textSecondary, + ), + ), + if (count > 0) ...[ + const SizedBox(width: 5), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: isSelected + ? Colors.white.withValues(alpha: 0.25) + : const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$count', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: isSelected ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ], + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildActivityCard(Map<String, dynamic> act) { + final isDone = act['isCompleted'] == true; + final title = act['title']?.toString() ?? ''; + final subtitle = act['description']?.toString() ?? ''; + final category = act['category']?.toString() ?? 'Mindfulness'; + final duration = act['duration']?.toString() ?? '5 Menit'; + final catColor = _getCategoryColor(category); + final icon = _getIconForActivity(act['iconName']?.toString(), category); + + return GlassCard( + width: double.infinity, + onTap: () => _showGuidanceModal(context, act), + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: isDone ? Colors.grey.shade400 : catColor.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + isDone ? Icons.check : icon, + color: isDone ? Colors.white : catColor, + size: 22, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: catColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + category, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: catColor, + ), + ), + ), + const SizedBox(width: 8), + Text( + '• $duration', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textLight, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + title, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: isDone ? AppColors.textSecondary : AppColors.textPrimary, + decoration: isDone ? TextDecoration.lineThrough : null, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + isDone ? Icons.check_circle : Icons.chevron_right, + color: isDone ? const Color(0xFF10B981) : AppColors.textLight, + size: 22, + ), + onPressed: () => _showGuidanceModal(context, act), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 36), + child: Column( + children: [ + const Icon(Icons.self_improvement, size: 48, color: Color(0xFFCBD5E1)), + const SizedBox(height: 12), + Text( + 'Belum ada panduan di kategori ini', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + 'Silakan pilih kategori "Semua" untuk melihat latihan lainnya.', + style: GoogleFonts.inter(fontSize: 12, color: AppColors.textLight), + ), + ], + ), + ), + ); + } + + /// Interactive step-by-step guidance bottom sheet + void _showGuidanceModal(BuildContext context, Map<String, dynamic> act) { + final actId = act['id']?.toString() ?? ''; + final title = act['title']?.toString() ?? 'Latihan Relaksasi'; + final category = act['category']?.toString() ?? 'Mindfulness'; + final duration = act['duration']?.toString() ?? '5 Menit'; + final difficulty = act['difficulty']?.toString() ?? 'Pemula'; + final description = act['description']?.toString() ?? ''; + final rationale = act['rationale']?.toString() ?? ''; + + List<String> instructions = []; + if (act['instructions'] is List) { + instructions = (act['instructions'] as List).map((e) => e.toString()).toList(); + } + if (instructions.isEmpty) { + // Fallback instructions if missing + instructions = [ + 'Cari posisi duduk yang santai dan nyaman.', + 'Tarik napas dalam perlahan dan hembuskan perlahan.', + 'Lakukan langkah latihan ini secara tenang selama $duration.', + 'Rasakan perubahan sensasi rileks di tubuhmu setelah selesai.' + ]; + } + + final isBreathingActivity = title.toLowerCase().contains('napas') || + title.toLowerCase().contains('breathing') || + category.toLowerCase() == 'mindfulness'; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return GuidanceModalContent( + actId: actId, + title: title, + category: category, + duration: duration, + difficulty: difficulty, + description: description, + rationale: rationale, + instructions: instructions, + isBreathingActivity: isBreathingActivity, + isCompleted: act['isCompleted'] == true, + onComplete: () { + _completeRecommendation(actId); + Navigator.pop(ctx); + }, + ); + }, + ); + } +} diff --git a/apps/eva_mobile/lib/features/recommendations/presentation/widgets/guidance_modal_content.dart b/apps/eva_mobile/lib/features/recommendations/presentation/widgets/guidance_modal_content.dart new file mode 100644 index 0000000..1537763 --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/presentation/widgets/guidance_modal_content.dart @@ -0,0 +1,413 @@ +import "dart:async"; +import "package:flutter/material.dart"; +import "package:google_fonts/google_fonts.dart"; + +import "../../../../theme/app_colors.dart"; + +class GuidanceModalContent extends StatefulWidget { + final String actId; + final String title; + final String category; + final String duration; + final String difficulty; + final String description; + final String rationale; + final List<String> instructions; + final bool isBreathingActivity; + final bool isCompleted; + final VoidCallback onComplete; + + const GuidanceModalContent({ + super.key, + required this.actId, + required this.title, + required this.category, + required this.duration, + required this.difficulty, + required this.description, + required this.rationale, + required this.instructions, + required this.isBreathingActivity, + required this.isCompleted, + required this.onComplete, + }); + + @override + State<GuidanceModalContent> createState() => GuidanceModalContentState(); +} + +class GuidanceModalContentState extends State<GuidanceModalContent> { + bool _isBreathPacerActive = false; + int _breathTimerSec = 0; + String _breathPhase = 'Tarik Napas'; + Timer? _breathTimer; + + @override + void dispose() { + _breathTimer?.cancel(); + super.dispose(); + } + + void _toggleBreathPacer() { + if (_isBreathPacerActive) { + _breathTimer?.cancel(); + setState(() { + _isBreathPacerActive = false; + _breathTimerSec = 0; + _breathPhase = 'Selesai'; + }); + } else { + setState(() { + _isBreathPacerActive = true; + _breathTimerSec = 0; + _breathPhase = 'Tarik Napas (4 dtk)'; + }); + + _breathTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return; + setState(() { + _breathTimerSec++; + final cycle = _breathTimerSec % 19; + if (cycle < 4) { + _breathPhase = 'Tarik Napas Perlahan (4s)'; + } else if (cycle < 11) { + _breathPhase = 'Tahan Napas di Dada (7s)'; + } else { + _breathPhase = 'Hembuskan Napas Rileks (8s)'; + } + }); + }); + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: const EdgeInsets.fromLTRB(22, 14, 22, 24), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.88, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + + // Header tags & close + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + widget.category.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 8), + Text( + '• ${widget.duration} • ${widget.difficulty}', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 8), + + // Title + Text( + widget.title, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + widget.description, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 14), + + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Rationale Card + if (widget.rationale.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFDDD6FE)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.lightbulb_outline, + size: 18, + color: Color(0xFF7C3AED), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Kenapa Latihan Ini Membantu?', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF5B21B6), + ), + ), + const SizedBox(height: 3), + Text( + widget.rationale, + style: GoogleFonts.inter( + fontSize: 11.5, + height: 1.4, + color: const Color(0xFF6D28D9), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // 2. Interactive Breath Pacer (if relevant) + if (widget.isBreathingActivity) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFFEEF2FF), + const Color(0xFFE0E7FF), + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.air, size: 16, color: Color(0xFF4F46E5)), + const SizedBox(width: 6), + Text( + 'Pemandu Irama Napas', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF3730A3), + ), + ), + ], + ), + GestureDetector( + onTap: _toggleBreathPacer, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: _isBreathPacerActive + ? const Color(0xFFEF4444) + : const Color(0xFF4F46E5), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _isBreathPacerActive ? 'Hentikan' : 'Mulai Pemandu', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + if (_isBreathPacerActive) ...[ + const SizedBox(height: 14), + AnimatedContainer( + duration: const Duration(milliseconds: 700), + width: _breathPhase.contains('Tarik') ? 80 : 65, + height: _breathPhase.contains('Tarik') ? 80 : 65, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF6366F1).withValues(alpha: 0.2), + border: Border.all(color: const Color(0xFF4F46E5), width: 2), + ), + child: Center( + child: Text( + '${_breathTimerSec}s', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: const Color(0xFF3730A3), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + _breathPhase, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF4338CA), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + ], + + // 3. Step-by-step instructions + Text( + 'Langkah-Langkah yang Perlu Dilakukan:', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.instructions.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, idx) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.primaryContainer, + shape: BoxShape.circle, + ), + child: Text( + '${idx + 1}', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.instructions[idx], + style: GoogleFonts.inter( + fontSize: 13, + height: 1.4, + color: AppColors.textPrimary, + ), + ), + ), + ], + ); + }, + ), + const SizedBox(height: 20), + ], + ), + ), + ), + + // Completion Button CTA + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: widget.onComplete, + icon: Icon( + widget.isCompleted ? Icons.check_circle : Icons.check, + color: Colors.white, + size: 18, + ), + label: Text( + widget.isCompleted + ? 'Sudah Diselesaikan Hari Ini' + : 'Saya Sudah Melakukan Latihan Ini', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: widget.isCompleted + ? const Color(0xFF10B981) + : AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + ), + ), + ], + ), + ); + } +} + diff --git a/apps/eva_mobile/lib/features/recommendations/presentation/widgets/recommendation_skeleton_loader.dart b/apps/eva_mobile/lib/features/recommendations/presentation/widgets/recommendation_skeleton_loader.dart new file mode 100644 index 0000000..f54010b --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/presentation/widgets/recommendation_skeleton_loader.dart @@ -0,0 +1,102 @@ +import "package:flutter/material.dart"; + +import "../../../../widgets/glass_card.dart"; +import "../../../../widgets/skeleton_shimmer.dart"; + +class RecommendationSkeletonLoader extends StatelessWidget { + const RecommendationSkeletonLoader({super.key}); + + @override + Widget build(BuildContext context) { + return SkeletonShimmerHost( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20.0, 8.0, 20.0, 80.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Condition Header Card Skeleton + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SkeletonCircle(size: 38), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 140, height: 16), + SizedBox(height: 6), + SkeletonLine(width: 90, height: 12), + ], + ), + ], + ), + const SizedBox(height: 16), + const SkeletonLine(width: double.infinity, height: 13), + const SizedBox(height: 8), + const SkeletonLine(width: 220, height: 13), + ], + ), + ), + const SizedBox(height: 20), + + // Featured Activity Skeleton + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonBox(width: 110, height: 22, borderRadius: 8), + SizedBox(height: 14), + SkeletonLine(width: 200, height: 18), + SizedBox(height: 10), + SkeletonLine(width: double.infinity, height: 13), + SizedBox(height: 6), + SkeletonLine(width: 260, height: 13), + SizedBox(height: 16), + SkeletonBox(width: double.infinity, height: 44, borderRadius: 12), + ], + ), + ), + const SizedBox(height: 20), + + // Activity List Skeletons + ...List.generate( + 2, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 14.0), + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const SkeletonCircle(size: 42), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 150, height: 15), + SizedBox(height: 8), + SkeletonLine(width: 100, height: 12), + ], + ), + ), + const SkeletonBox(width: 28, height: 28, borderRadius: 8), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/tutorial/data/repositories/tutorial_repository_impl.dart b/apps/eva_mobile/lib/features/tutorial/data/repositories/tutorial_repository_impl.dart new file mode 100644 index 0000000..67a0fea --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/data/repositories/tutorial_repository_impl.dart @@ -0,0 +1,32 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../../domain/repositories/tutorial_repository.dart'; + +class TutorialRepositoryImpl implements TutorialRepository { + static const String _keyCompleted = 'eva_tutorial_completed_v1'; + + @override + Future<bool> hasCompletedTutorial() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyCompleted) ?? false; + } catch (_) { + return false; + } + } + + @override + Future<void> setTutorialCompleted(bool completed) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyCompleted, completed); + } catch (_) {} + } + + @override + Future<void> resetTutorial() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyCompleted); + } catch (_) {} + } +} diff --git a/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart b/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart new file mode 100644 index 0000000..6d34efa --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart @@ -0,0 +1,138 @@ +enum TutorialStep { + voiceSession, + voiceCoins, + voiceStart, + voiceCompleted, + journal, + analytics, + dassDetail, + dassItem, + care, +} + +enum SpotlightShape { + circle, + roundedRectangle, +} + +enum PointerDirection { + up, + down, + none, +} + +class TutorialContent { + final String title; + final String description; + final String? actionHint; + final String badgeText; + final SpotlightShape shape; + final PointerDirection pointerDirection; + final bool isInteractiveTarget; + final String nextButtonLabel; + + const TutorialContent({ + required this.title, + required this.description, + this.actionHint, + this.badgeText = 'EVA AI', + this.shape = SpotlightShape.circle, + this.pointerDirection = PointerDirection.down, + this.isInteractiveTarget = true, + this.nextButtonLabel = 'Lanjut', + }); + + static const Map<TutorialStep, TutorialContent> stepContents = { + TutorialStep.voiceSession: TutorialContent( + title: 'Cerita ke Eva, yuk!', + description: + 'Tekan tombol tengah untuk mulai berbicara. Kamu bisa mulai saat sudah siap.', + actionHint: 'Ketuk tombol di bawah untuk mencoba', + badgeText: 'REKOMENDASI EVA', + shape: SpotlightShape.circle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: true, + ), + TutorialStep.voiceCoins: TutorialContent( + title: 'Telepon Pakai Koin', + description: + 'Setiap sesi suara bersama Eva menggunakan koin. Pantau saldo atau isi ulang koinmu kapan saja di sini.', + actionHint: 'Ketuk koin atau tombol Lanjut', + badgeText: 'INFO KOIN', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.up, + isInteractiveTarget: false, + ), + TutorialStep.voiceStart: TutorialContent( + title: 'Mulai Sesi Suara', + description: + 'Tekan tombol Mulai Sesi Suara untuk memulai percakapan langsung dan mencurahkan perasaanmu kepada Eva.', + actionHint: 'Ketuk Mulai Sesi Suara', + badgeText: 'SESI SUARA', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: true, + ), + TutorialStep.voiceCompleted: TutorialContent( + title: 'Refleksi Sesi Suara', + description: + 'Sesi suaramu telah tersimpan. Buka jurnal refleksi untuk melihat intisari emosi dan renungan harianmu.', + actionHint: 'Ketuk Lihat Detail Jurnal Refleksi', + badgeText: 'REFLEKSI AI', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: true, + ), + TutorialStep.journal: TutorialContent( + title: 'Jurnal Refleksi Otomatis', + description: + 'Setiap selesai bercerita, intisari perasaan dan renunganmu otomatis tersusun rapi di sini secara privat.', + actionHint: 'Buka kapan saja untuk membaca catatanmu', + badgeText: 'JURNAL AI', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: false, + ), + TutorialStep.analytics: TutorialContent( + title: 'Lembar Evaluasi DASS-21', + description: + 'AI merangkum indikator Stres, Cemas, dan Depresi secara otomatis. Ketuk untuk meninjau asesmenmu hari ini.', + actionHint: 'Ketuk Tinjau Asesmen Hari Ini atau Lanjut', + badgeText: 'ANALITIK DASS', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: false, + ), + TutorialStep.dassDetail: TutorialContent( + title: 'Tinjauan Indikator DASS-21', + description: + 'Di sini kamu dapat melihat skor klinis dan evaluasi suasana hatimu. Kamu memiliki kendali penuh untuk meninjau secara mandiri.', + actionHint: 'Ketuk Lanjut untuk melihat butir evaluasi', + badgeText: 'PENJELASAN DASS', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: false, + ), + TutorialStep.dassItem: TutorialContent( + title: 'Hasil Ekstraksi Otomatis AI', + description: + 'EVA otomatis mendeteksi butir ini dari percakapanmu. Jika skornya dirasa kurang pas, kamu bebas menggeser atau mengubahnya secara manual.', + actionHint: 'Ketuk Lanjut untuk melihat layanan bantuan', + badgeText: 'EKSTRAKSI AI', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: false, + ), + TutorialStep.care: TutorialContent( + title: 'Layanan & Dukungan Sebaya', + description: + 'Jika butuh teman bicara manusia, temukan konseling profesional dan volunteer terpercaya di sini.', + actionHint: 'Bantuan selalu tersedia di menu Layanan', + badgeText: 'LAYANAN CARE', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: false, + nextButtonLabel: 'Selesai', + ), + }; +} diff --git a/apps/eva_mobile/lib/features/tutorial/domain/repositories/tutorial_repository.dart b/apps/eva_mobile/lib/features/tutorial/domain/repositories/tutorial_repository.dart new file mode 100644 index 0000000..9bb5957 --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/domain/repositories/tutorial_repository.dart @@ -0,0 +1,5 @@ +abstract class TutorialRepository { + Future<bool> hasCompletedTutorial(); + Future<void> setTutorialCompleted(bool completed); + Future<void> resetTutorial(); +} diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/providers/tutorial_provider.dart b/apps/eva_mobile/lib/features/tutorial/presentation/providers/tutorial_provider.dart new file mode 100644 index 0000000..b6d05c8 --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/presentation/providers/tutorial_provider.dart @@ -0,0 +1,182 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../data/repositories/tutorial_repository_impl.dart'; +import '../../domain/entities/tutorial_step.dart'; +import '../../domain/repositories/tutorial_repository.dart'; + +final tutorialRepositoryProvider = Provider<TutorialRepository>((ref) { + return TutorialRepositoryImpl(); +}); + +class TutorialState { + final bool isInitialized; + final bool isActive; + final bool isCompleted; + final TutorialStep? currentStep; + + const TutorialState({ + this.isInitialized = false, + this.isActive = false, + this.isCompleted = false, + this.currentStep, + }); + + TutorialState copyWith({ + bool? isInitialized, + bool? isActive, + bool? isCompleted, + TutorialStep? currentStep, + bool clearStep = false, + }) { + return TutorialState( + isInitialized: isInitialized ?? this.isInitialized, + isActive: isActive ?? this.isActive, + isCompleted: isCompleted ?? this.isCompleted, + currentStep: clearStep ? null : (currentStep ?? this.currentStep), + ); + } +} + +class TutorialNotifier extends StateNotifier<TutorialState> { + final TutorialRepository _repository; + + TutorialNotifier(this._repository) : super(const TutorialState()) { + checkInitialStatus(); + } + + /// Always ensures the recommendation/tutorial guidance is active on app open or restart + Future<void> checkInitialStatus() async { + state = const TutorialState( + isInitialized: true, + isCompleted: false, + isActive: true, + currentStep: TutorialStep.voiceSession, + ); + } + + /// Explicitly triggers recommendation/tutorial on screen initialization/restart + void ensureActiveOnLaunch() { + state = const TutorialState( + isInitialized: true, + isCompleted: false, + isActive: true, + currentStep: TutorialStep.voiceSession, + ); + } + + Future<void> startTutorial() async { + state = state.copyWith( + isActive: true, + isCompleted: false, + currentStep: TutorialStep.voiceSession, + ); + } + + void nextStep() { + if (state.currentStep == null) return; + + final steps = TutorialStep.values; + final currentIndex = steps.indexOf(state.currentStep!); + + if (currentIndex + 1 < steps.length) { + state = state.copyWith( + currentStep: steps[currentIndex + 1], + ); + } else { + completeTutorial(); + } + } + + Future<void> onTargetTapped(TutorialStep step) async { + if (state.currentStep == step) { + if (step == TutorialStep.voiceSession) { + // Center button tapped -> navigate to /chat and highlight coin balance + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.voiceCoins, + ); + } else if (step == TutorialStep.voiceCoins) { + // Coin explanation acknowledged -> highlight Mulai Sesi Suara button + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.voiceStart, + ); + } else if (step == TutorialStep.voiceStart) { + // Voice call initiated -> pause spotlight overlay during active call + state = state.copyWith( + isActive: false, + currentStep: TutorialStep.voiceCompleted, + ); + } else if (step == TutorialStep.voiceCompleted) { + // User tapped Lihat Detail Jurnal Refleksi -> proceed to journal tab + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.journal, + ); + } else if (step == TutorialStep.journal) { + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.analytics, + ); + } else if (step == TutorialStep.analytics) { + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.dassDetail, + ); + } else if (step == TutorialStep.dassDetail) { + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.dassItem, + ); + } else if (step == TutorialStep.dassItem) { + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.care, + ); + } else if (step == TutorialStep.care) { + completeTutorial(); + } else { + nextStep(); + } + } + } + + void resumeAtStep(TutorialStep step) { + state = state.copyWith( + isActive: true, + currentStep: step, + ); + } + + /// Skips tutorial for the current session. Will show again on app restart as requested. + Future<void> skipTutorial() async { + state = state.copyWith( + isActive: false, + isCompleted: true, + clearStep: true, + ); + } + + Future<void> completeTutorial() async { + state = state.copyWith( + isActive: false, + isCompleted: true, + clearStep: true, + ); + } + + Future<void> resetTutorial() async { + await _repository.resetTutorial(); + state = const TutorialState( + isInitialized: true, + isActive: true, + isCompleted: false, + currentStep: TutorialStep.voiceSession, + ); + } +} + +final tutorialNotifierProvider = + StateNotifierProvider<TutorialNotifier, TutorialState>((ref) { + final repository = ref.watch(tutorialRepositoryProvider); + return TutorialNotifier(repository); +}); diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart new file mode 100644 index 0000000..281f71d --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/tutorial_step.dart'; + +/// A branded, calming guidance card styled specifically for Eva AI. +/// Combines typography hierarchy, subtle brand identity accents, and an optional +/// subtle downward/upward pointer connected seamlessly to the card. +class BrandedGuidanceCard extends StatelessWidget { + final String title; + final String description; + final String? actionHint; + final String badgeText; + final PointerDirection pointerDirection; + final double pointerHorizontalOffset; + final bool isInteractiveTarget; + final String nextButtonLabel; + final VoidCallback onSkip; + final VoidCallback? onNext; + + const BrandedGuidanceCard({ + super.key, + required this.title, + required this.description, + this.actionHint, + this.badgeText = 'EVA AI', + this.pointerDirection = PointerDirection.down, + this.pointerHorizontalOffset = 0.0, + this.isInteractiveTarget = true, + this.nextButtonLabel = 'Lanjut', + required this.onSkip, + this.onNext, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (pointerDirection == PointerDirection.up) ...[ + _buildPointer(isPointingUp: true), + ], + + // Main Card Surface + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: AppColors.primaryContainer.withValues(alpha: 0.9), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.12), + blurRadius: 30, + spreadRadius: 0, + offset: const Offset(0, 10), + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + spreadRadius: 0, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.fromLTRB(20, 16, 20, 18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row: Brand Badge + Skip Action + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Subtle Brand Chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4.5), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/images/eva_logo_square.png', + width: 14, + height: 14, + errorBuilder: (context, error, stackTrace) => const Icon( + Icons.auto_awesome, + size: 13, + color: AppColors.primary, + ), + ), + const SizedBox(width: 5), + Text( + badgeText, + style: GoogleFonts.inter( + fontSize: 10.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: AppColors.primary, + ), + ), + ], + ), + ), + + // Subtle "Lewati" Button + GestureDetector( + onTap: onSkip, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Text( + 'Lewati', + style: GoogleFonts.inter( + fontSize: 12.5, + fontWeight: FontWeight.w500, + color: AppColors.textLight, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Title + Text( + title, + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + height: 1.3, + ), + ), + const SizedBox(height: 6), + + // Description + Text( + description, + style: GoogleFonts.inter( + fontSize: 13.5, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + height: 1.45, + ), + ), + + // Action Cue Banner (if available) + if (actionHint != null && actionHint!.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8.5), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.primaryContainer.withValues(alpha: 0.5), + ), + ), + child: Row( + children: [ + const Icon( + Icons.touch_app_outlined, + size: 15, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + actionHint!, + style: GoogleFonts.inter( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ), + ], + ), + ), + ], + + // For non-interactive target (informational card), offer a primary "Lanjut" button + if (!isInteractiveTarget && onNext != null) ...[ + const SizedBox(height: 14), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton( + onPressed: onNext, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + nextButtonLabel, + style: GoogleFonts.inter( + fontSize: 12.5, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ], + ), + ), + + if (pointerDirection == PointerDirection.down) ...[ + _buildPointer(isPointingUp: false), + ], + ], + ); + } + + Widget _buildPointer({required bool isPointingUp}) { + return Transform.translate( + offset: Offset(pointerHorizontalOffset, isPointingUp ? 1.0 : -1.0), + child: CustomPaint( + size: const Size(18, 9), + painter: _SubtlePointerPainter( + isPointingUp: isPointingUp, + fillColor: Colors.white, + borderColor: AppColors.primaryContainer.withValues(alpha: 0.9), + ), + ), + ); + } +} + +class _SubtlePointerPainter extends CustomPainter { + final bool isPointingUp; + final Color fillColor; + final Color borderColor; + + _SubtlePointerPainter({ + required this.isPointingUp, + required this.fillColor, + required this.borderColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final path = Path(); + final borderPath = Path(); + + if (isPointingUp) { + // Triangle pointing upward: bottom-left to top-center to bottom-right + path.moveTo(0, size.height); + path.lineTo(size.width / 2, 0); + path.lineTo(size.width, size.height); + path.close(); + + borderPath.moveTo(0, size.height); + borderPath.lineTo(size.width / 2, 0); + borderPath.lineTo(size.width, size.height); + } else { + // Triangle pointing downward: top-left to bottom-center to top-right + path.moveTo(0, 0); + path.lineTo(size.width / 2, size.height); + path.lineTo(size.width, 0); + path.close(); + + borderPath.moveTo(0, 0); + borderPath.lineTo(size.width / 2, size.height); + borderPath.lineTo(size.width, 0); + } + + final fillPaint = Paint() + ..color = fillColor + ..style = PaintingStyle.fill; + canvas.drawPath(path, fillPaint); + + final borderPaint = Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + canvas.drawPath(borderPath, borderPaint); + } + + @override + bool shouldRepaint(covariant _SubtlePointerPainter oldDelegate) { + return oldDelegate.isPointingUp != isPointingUp || + oldDelegate.fillColor != fillColor || + oldDelegate.borderColor != borderColor; + } +} diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart new file mode 100644 index 0000000..998c0d2 --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart @@ -0,0 +1,481 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/tutorial_step.dart'; +import 'branded_guidance_card.dart'; + +/// An interactive, branded spotlight overlay for Eva AI. +/// Renders a dimmed, calming backdrop with an exact cutout around the target widget, +/// soft ambient halo ring, and a dynamically positioned [BrandedGuidanceCard]. +/// +/// Uses standard declarative Flutter layers: +/// 1. Fullscreen modal backdrop capturing taps outside the target. +/// 2. Direct tap target over the cutout hole invoking [onTargetTap]. +/// 3. Dynamically positioned guidance card with action cues and skip option. +class InteractiveSpotlightOverlay extends StatefulWidget { + final GlobalKey? targetKey; + final Rect? targetRect; + final GlobalKey? secondaryTargetKey; + final Rect? secondaryTargetRect; + final TutorialContent content; + final VoidCallback onSkip; + final VoidCallback? onTargetTap; + final VoidCallback? onNext; + final double cutoutPadding; + + const InteractiveSpotlightOverlay({ + super.key, + this.targetKey, + this.targetRect, + this.secondaryTargetKey, + this.secondaryTargetRect, + required this.content, + required this.onSkip, + this.onTargetTap, + this.onNext, + this.cutoutPadding = 8.0, + }); + + @override + State<InteractiveSpotlightOverlay> createState() => + _InteractiveSpotlightOverlayState(); +} + +class _InteractiveSpotlightOverlayState extends State<InteractiveSpotlightOverlay> + with TickerProviderStateMixin { + late final AnimationController _fadeController; + late final AnimationController _haloController; + Rect? _measuredTargetRect; + Rect? _measuredSecondaryTargetRect; + Timer? _pollingTimer; + + @override + void initState() { + super.initState(); + _fadeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + ); + + _haloController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2400), + ); + + _scheduleMeasurement(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final disableAnimations = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + if (disableAnimations) { + _fadeController.value = 1.0; + } else { + _fadeController.forward(); + _haloController.repeat(reverse: true); + } + } + }); + } + + void _scheduleMeasurement() { + _pollingTimer?.cancel(); + + void measure() { + if (!mounted) return; + _updateTargetMeasurement(); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + measure(); + if ((_measuredTargetRect == null || _measuredTargetRect!.top < 0) && + widget.targetKey != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => measure()); + } + }); + + int attempts = 0; + _pollingTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) { + attempts++; + if (!mounted) { + timer.cancel(); + return; + } + _updateTargetMeasurement(); + // Keep tracking for up to 20 frames (1s) to accommodate page scroll and layout changes + if (attempts >= 20) { + timer.cancel(); + } + }); + } + + @override + void didUpdateWidget(covariant InteractiveSpotlightOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.targetKey != widget.targetKey || + oldWidget.targetRect != widget.targetRect || + oldWidget.secondaryTargetKey != widget.secondaryTargetKey || + oldWidget.secondaryTargetRect != widget.secondaryTargetRect) { + _measuredTargetRect = null; + _measuredSecondaryTargetRect = null; + _scheduleMeasurement(); + } + } + + Rect? _measureKey(GlobalKey? key) { + if (key == null) return null; + + final targetContext = key.currentContext; + if (targetContext == null) return null; + + final renderBox = targetContext.findRenderObject() as RenderBox?; + if (renderBox != null && renderBox.attached && renderBox.hasSize) { + final size = renderBox.size; + if (size.width <= 0 || size.height <= 0) return null; + + final overlayBox = context.findRenderObject() as RenderBox?; + final Offset offset; + if (overlayBox != null && overlayBox.attached) { + offset = overlayBox.globalToLocal(renderBox.localToGlobal(Offset.zero)); + } else { + offset = renderBox.localToGlobal(Offset.zero); + } + + final mediaQuery = MediaQuery.of(context); + final screenSize = mediaQuery.size; + + final rect = offset & size; + + // Validate that target widget has rendered dimensions and is within screen boundaries + if (rect.right <= 0 || + rect.left >= screenSize.width || + rect.bottom <= 0 || + rect.top >= screenSize.height) { + return null; + } + + return rect; + } + return null; + } + + void _updateTargetMeasurement() { + if (!mounted) return; + + final primary = widget.targetRect ?? _measureKey(widget.targetKey); + final secondary = + widget.secondaryTargetRect ?? _measureKey(widget.secondaryTargetKey); + + if (_measuredTargetRect != primary || + _measuredSecondaryTargetRect != secondary) { + setState(() { + _measuredTargetRect = primary; + _measuredSecondaryTargetRect = secondary; + }); + } + } + + @override + void dispose() { + _pollingTimer?.cancel(); + _fadeController.dispose(); + _haloController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if ((_measuredTargetRect == null || _measuredTargetRect!.top < 0) && + widget.targetKey != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateTargetMeasurement(); + }); + } + + final mediaQuery = MediaQuery.of(context); + final screenSize = mediaQuery.size; + + // Use measured rect if available, or compute fallback for center button on frame 0 + final effectiveTargetRect = _measuredTargetRect ?? + widget.targetRect ?? + (widget.content.shape == SpotlightShape.circle + ? Rect.fromCircle( + center: Offset(screenSize.width / 2, screenSize.height - 59), + radius: 29, + ) + : null); + + final effectiveSecondaryRect = + _measuredSecondaryTargetRect ?? widget.secondaryTargetRect; + + return AnimatedBuilder( + animation: Listenable.merge([_fadeController, _haloController]), + builder: (context, _) { + final opacity = _fadeController.value; + final haloValue = _haloController.value; + + if (opacity <= 0.0) return const SizedBox.shrink(); + + return Opacity( + opacity: opacity, + child: Stack( + fit: StackFit.expand, + children: [ + // 1. Fullscreen Dimmed Backdrop (Absorbs taps outside target hole) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + // Absorbs taps on dimmed area so background buttons cannot be accidentally clicked + }, + child: CustomPaint( + size: Size.infinite, + painter: _SpotlightCutoutPainter( + targetRect: effectiveTargetRect, + secondaryTargetRect: effectiveSecondaryRect, + shape: widget.content.shape, + padding: widget.cutoutPadding, + haloOpacity: 0.35 + (0.30 * haloValue), + ), + ), + ), + ), + + // 2. Direct Tap Target for the Highlighted Control(s) + if (widget.onTargetTap != null) ...[ + if (effectiveTargetRect != null) + Positioned.fromRect( + rect: effectiveTargetRect.inflate(widget.cutoutPadding), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: widget.onTargetTap, + ), + ), + if (effectiveSecondaryRect != null) + Positioned.fromRect( + rect: effectiveSecondaryRect.inflate(widget.cutoutPadding), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: widget.onTargetTap, + ), + ), + ], + + // 3. Dynamically Positioned Guidance Card (Always rendered!) + _buildPositionedCard(effectiveTargetRect), + ], + ), + ); + }, + ); + } + + Widget _buildPositionedCard(Rect? targetRect) { + final mediaQuery = MediaQuery.of(context); + final screenSize = mediaQuery.size; + final safePadding = mediaQuery.padding; + + const double cardMaxWidth = 360.0; + final double cardWidth = math.min(screenSize.width - 32.0, cardMaxWidth); + final double minLeft = safePadding.left + 16.0; + + if (targetRect == null) { + // Safe fallback when target is still mounting or asynchronous data is loading: + // Always render card safely at bottom so user is NEVER blocked by a black screen! + final double cardLeft = ((screenSize.width - cardWidth) / 2).clamp(minLeft, screenSize.width - cardWidth); + return Positioned( + left: cardLeft, + width: cardWidth, + bottom: math.max(safePadding.bottom + 80.0, 100.0), + child: SafeArea( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: BrandedGuidanceCard( + title: widget.content.title, + description: widget.content.description, + actionHint: widget.content.actionHint, + badgeText: widget.content.badgeText, + pointerDirection: PointerDirection.none, + isInteractiveTarget: widget.content.isInteractiveTarget, + nextButtonLabel: widget.content.nextButtonLabel, + onSkip: widget.onSkip, + onNext: widget.onNext, + ), + ), + ), + ); + } + + // Calculate vertical placement + final double spaceAbove = targetRect.top - safePadding.top; + final double spaceBelow = + screenSize.height - safePadding.bottom - targetRect.bottom; + + // Prefer placing card below target; only place above if not enough space below AND more space above + final bool placeAbove = (spaceBelow < 280 && spaceAbove > spaceBelow); + + // Horizontal placement: Center card relative to target, clamped to screen safe bounds + final double targetCenterX = targetRect.center.dx; + double cardLeft = targetCenterX - (cardWidth / 2); + final double maxLeft = screenSize.width - safePadding.right - 16.0 - cardWidth; + cardLeft = cardLeft.clamp(minLeft, math.max(minLeft, maxLeft)); + + // Pointer offset relative to card center + final double cardCenterX = cardLeft + (cardWidth / 2); + final double pointerOffset = (targetCenterX - cardCenterX).clamp( + -(cardWidth / 2) + 28.0, + (cardWidth / 2) - 28.0, + ); + + const double gapToCutout = 10.0; + + return Positioned( + left: cardLeft, + width: cardWidth, + top: placeAbove ? null : targetRect.bottom + widget.cutoutPadding + gapToCutout, + bottom: placeAbove + ? (screenSize.height - targetRect.top) + widget.cutoutPadding + gapToCutout + : null, + child: SafeArea( + top: !placeAbove, + bottom: true, + minimum: const EdgeInsets.only(left: 4, right: 4, bottom: 20), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: BrandedGuidanceCard( + title: widget.content.title, + description: widget.content.description, + actionHint: widget.content.actionHint, + badgeText: widget.content.badgeText, + pointerDirection: + placeAbove ? PointerDirection.down : PointerDirection.up, + pointerHorizontalOffset: pointerOffset, + isInteractiveTarget: widget.content.isInteractiveTarget, + nextButtonLabel: widget.content.nextButtonLabel, + onSkip: widget.onSkip, + onNext: widget.onNext, + ), + ), + ), + ); + } +} + +/// Custom painter for the dimmed backdrop, cutout, and ambient halo glow ring. +class _SpotlightCutoutPainter extends CustomPainter { + final Rect? targetRect; + final Rect? secondaryTargetRect; + final SpotlightShape shape; + final double padding; + final double haloOpacity; + + _SpotlightCutoutPainter({ + required this.targetRect, + this.secondaryTargetRect, + required this.shape, + required this.padding, + required this.haloOpacity, + }); + + @override + void paint(Canvas canvas, Size size) { + final screenPath = Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); + + if (targetRect == null && secondaryTargetRect == null) { + // While measuring or waiting for target to mount, paint a subtle translucent dim + // instead of solid black, so the user clearly sees the screen loading underneath + canvas.drawPath( + screenPath, + Paint()..color = const Color(0xFF0F1123).withValues(alpha: 0.35), + ); + return; + } + + final Path holePath = Path(); + + if (targetRect != null) { + if (shape == SpotlightShape.circle) { + final center = targetRect!.center; + final radius = + (math.max(targetRect!.width, targetRect!.height) / 2) + padding; + holePath.addOval(Rect.fromCircle(center: center, radius: radius)); + } else { + final rect = targetRect!.inflate(padding); + holePath.addRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(16)), + ); + } + } + + if (secondaryTargetRect != null) { + final rect = secondaryTargetRect!.inflate(padding); + holePath.addRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(16)), + ); + } + + // Cutout difference path + final cutoutPath = Path.combine(PathOperation.difference, screenPath, holePath); + + // 1. Draw dimmed backdrop + canvas.drawPath( + cutoutPath, + Paint() + ..color = const Color(0xFF0F1123).withValues(alpha: 0.72) + ..style = PaintingStyle.fill, + ); + + // 2. Draw subtle ambient halo ring around hole(s) + final haloGlowPaint = Paint() + ..color = AppColors.primaryLight.withValues(alpha: haloOpacity * 0.45) + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); + canvas.drawPath(holePath, haloGlowPaint); + + // 3. Draw clean crisp highlight border on hole edge(s) + final borderPaint = Paint() + ..color = AppColors.primaryContainer.withValues(alpha: haloOpacity) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawPath(holePath, borderPaint); + } + + @override + bool? hitTest(Offset position) { + if (targetRect == null && secondaryTargetRect == null) return true; + + if (targetRect != null) { + final holeRect = shape == SpotlightShape.circle + ? Rect.fromCircle( + center: targetRect!.center, + radius: (math.max(targetRect!.width, targetRect!.height) / 2) + padding, + ) + : targetRect!.inflate(padding); + + if (holeRect.contains(position)) { + return false; + } + } + + if (secondaryTargetRect != null) { + final secondaryHoleRect = secondaryTargetRect!.inflate(padding); + if (secondaryHoleRect.contains(position)) { + return false; + } + } + + return true; + } + + @override + bool shouldRepaint(covariant _SpotlightCutoutPainter oldDelegate) { + return oldDelegate.targetRect != targetRect || + oldDelegate.secondaryTargetRect != secondaryTargetRect || + oldDelegate.shape != shape || + oldDelegate.padding != padding || + oldDelegate.haloOpacity != haloOpacity; + } +} diff --git a/apps/eva_mobile/lib/features/voice_call/data/repositories/fallback_voice_call_repository.dart b/apps/eva_mobile/lib/features/voice_call/data/repositories/fallback_voice_call_repository.dart new file mode 100644 index 0000000..4549e26 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/data/repositories/fallback_voice_call_repository.dart @@ -0,0 +1,41 @@ +import '../../domain/repositories/voice_call_repository.dart'; +import '../../domain/entities/voice_session_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackVoiceCallRepository implements VoiceCallRepository { + final VoiceCallRepository _remote; + final VoiceCallRepository _mock; + + FallbackVoiceCallRepository({ + required VoiceCallRepository remote, + required VoiceCallRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future<VoiceSessionEntity> startCall() { + return FallbackExecutor.execute( + remoteCall: () => _remote.startCall(), + mockCall: () => _mock.startCall(), + operationName: 'startCall', + ); + } + + @override + Future<VoiceSessionEntity> endCall(String sessionId, int durationSeconds) { + return FallbackExecutor.execute( + remoteCall: () => _remote.endCall(sessionId, durationSeconds), + mockCall: () => _mock.endCall(sessionId, durationSeconds), + operationName: 'endCall($sessionId)', + ); + } + + @override + Future<VoiceSessionEntity?> getSessionSummary(String sessionId) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getSessionSummary(sessionId), + mockCall: () => _mock.getSessionSummary(sessionId), + operationName: 'getSessionSummary($sessionId)', + ); + } +} diff --git a/apps/eva_mobile/lib/features/voice_call/data/services/speech_recognition_service.dart b/apps/eva_mobile/lib/features/voice_call/data/services/speech_recognition_service.dart index 1c66233..d2db5f6 100644 --- a/apps/eva_mobile/lib/features/voice_call/data/services/speech_recognition_service.dart +++ b/apps/eva_mobile/lib/features/voice_call/data/services/speech_recognition_service.dart @@ -200,9 +200,6 @@ class SpeechRecognitionService { _soundLevelController.add(normalized); } }, - localeId: _resolvedLocaleId, - listenFor: const Duration(seconds: 60), - pauseFor: const Duration(milliseconds: 2500), listenOptions: stt.SpeechListenOptions( localeId: _resolvedLocaleId, listenMode: stt.ListenMode.dictation, diff --git a/apps/eva_mobile/lib/features/voice_call/presentation/providers/voice_call_provider.dart b/apps/eva_mobile/lib/features/voice_call/presentation/providers/voice_call_provider.dart index 1027198..d8f785c 100644 --- a/apps/eva_mobile/lib/features/voice_call/presentation/providers/voice_call_provider.dart +++ b/apps/eva_mobile/lib/features/voice_call/presentation/providers/voice_call_provider.dart @@ -1,16 +1,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/voice_call_repository.dart'; import '../../domain/entities/voice_session_entity.dart'; import '../../data/datasources/mock_voice_call_datasource.dart'; import '../../data/datasources/remote_voice_call_datasource.dart'; import '../../data/repositories/voice_call_repository_impl.dart'; +import '../../data/repositories/fallback_voice_call_repository.dart'; final voiceCallRepositoryProvider = Provider<VoiceCallRepository>((ref) { + final mockRepo = VoiceCallRepositoryImpl(MockVoiceCallDataSource()); + final remoteRepo = VoiceCallRepositoryImpl(RemoteVoiceCallDataSource()); + if (AppConfig.useMockData) { - return VoiceCallRepositoryImpl(MockVoiceCallDataSource()); + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackVoiceCallRepository(remote: remoteRepo, mock: mockRepo); } else { - return VoiceCallRepositoryImpl(RemoteVoiceCallDataSource()); + return remoteRepo; } }); diff --git a/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_call_screen.dart b/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_call_screen.dart new file mode 100644 index 0000000..7a75195 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_call_screen.dart @@ -0,0 +1,1204 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/voice_call/domain/entities/call_state.dart'; +import 'package:eva_mobile/features/voice_call/presentation/controllers/ai_call_controller.dart'; +import 'package:eva_mobile/features/voice_call/presentation/controllers/ai_call_state.dart'; +import 'package:eva_mobile/features/voice_call/presentation/providers/ai_call_provider.dart'; +import 'package:eva_mobile/features/voice_call/presentation/widgets/morphing_voice_nodes_visualizer.dart'; +import 'package:eva_mobile/services/daily_progress_local_service.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/features/emergency/presentation/screens/support_emergency_screen.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/presentation/providers/tutorial_provider.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; + +class VoiceCallScreen extends ConsumerStatefulWidget { + const VoiceCallScreen({super.key}); + + @override + ConsumerState<VoiceCallScreen> createState() => _VoiceCallScreenState(); +} + +class _VoiceCallScreenState extends ConsumerState<VoiceCallScreen> + with TickerProviderStateMixin { + late final AnimationController _aiRippleController; + + @override + void initState() { + super.initState(); + _aiRippleController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 3500), + )..repeat(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(aiCallControllerProvider.notifier).startCall(); + }); + } + + @override + void dispose() { + _aiRippleController.dispose(); + super.dispose(); + } + + String _formatDuration(int totalSeconds) { + final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0'); + final seconds = (totalSeconds % 60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + void _handleExitCall(AiCallViewState state) { + if (state.isCrisisSession) { + ref.read(aiCallControllerProvider.notifier).endCall(); + Navigator.pushReplacement(context, SupportEmergencyScreen.route()); + } else { + _showSessionSummaryBottomSheet(state.callDurationSeconds); + } + } + + bool _isHighDistressSession(AiCallViewState state) { + if (state.crisisHotline != null) return true; + final text = '${state.currentTranscript} ${state.aiTranscript}'.toLowerCase(); + final highDistressKeywords = [ + 'krisis', + 'panik', + 'sesak', + 'bunuh diri', + 'menyakiti', + 'tidak sanggup', + 'kelelahan mental', + 'anxiety berat', + 'depresi berat', + 'putus asa', + 'tidak ada harapan', + 'takut sekali', + 'stres berat', + ]; + return highDistressKeywords.any((k) => text.contains(k)); + } + + bool _isDassTriggeredSession(AiCallViewState state) { + if (state.crisisHotline != null) return true; + final text = '${state.currentTranscript} ${state.aiTranscript}'.toLowerCase(); + final dassKeywords = [ + 'dass', + 'skala 0', + 'skor 0', + 'pertanyaan asesmen', + 'gejala yang kamu rasakan', + 'seberapa sering kamu merasa', + 'jantung berdebar', + 'sulit bernapas', + 'cemas berlebihan', + 'merasa putus asa', + 'kehilangan minat', + 'panik', + 'stres berat', + 'depresi berat', + ]; + return dassKeywords.any((k) => text.contains(k)); + } + + void _showSessionSummaryBottomSheet(int durationSeconds) { + final state = ref.read(aiCallControllerProvider); + ref.read(aiCallControllerProvider.notifier).endCall(); + final isHighDistress = _isHighDistressSession(state); + final hasDassTriggered = _isDassTriggeredSession(state); + + if (isHighDistress) { + _showPostCallCrisisModal(durationSeconds); + } else { + _showNormalSessionSummaryBottomSheet( + durationSeconds, + false, + hasDassTriggered: hasDassTriggered, + ); + } + } + + void _showPostCallCrisisModal(int durationSeconds) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Dialog( + backgroundColor: Colors.white, + insetPadding: const EdgeInsets.symmetric(horizontal: 22, vertical: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + child: Padding( + padding: const EdgeInsets.all(22), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Glowing Heart Icon + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFFFE0E3), + boxShadow: [ + BoxShadow( + color: const Color(0xFFE57373).withValues(alpha: 0.25), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + child: const Icon( + Icons.favorite, + color: Color(0xFFE57373), + size: 32, + ), + ), + const SizedBox(height: 18), + + // Title + Text( + 'Deteksi Beban Emosional Berat 🍃', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + + // Subtitle + Text( + 'EVA mendeteksi tingkat ketegangan dan beban emosional yang tinggi dalam percakapan tadi. Kamu tidak harus memikulnya sendirian.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.45, + ), + ), + const SizedBox(height: 16), + + // Reassurance info box + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF5F5), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: const Color(0xFFE57373).withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + const Icon( + Icons.air_rounded, + color: Color(0xFFE57373), + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Tersedia Latihan Napas 4-7-8 untuk menstabilkan detak jantung, kontak orang terpercaya, dan hotline krisis.', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: const Color(0xFF881337), + height: 1.35, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + // Action 1: Open Crisis Support (Pink/Rose button) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFE57373), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + padding: const EdgeInsets.symmetric(vertical: 13), + ), + icon: const Icon(Icons.favorite_rounded, size: 18), + label: Text( + 'Buka Latihan Napas & Bantuan Krisis', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + onPressed: () { + Navigator.pop(context); + Navigator.pushReplacementNamed(context, '/support'); + }, + ), + ), + const SizedBox(height: 10), + + // Action 2: Review Session Summary + SizedBox( + width: double.infinity, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textSecondary, + side: const BorderSide(color: Color(0xFFE2E8F0)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: () { + Navigator.pop(context); + _showNormalSessionSummaryBottomSheet( + durationSeconds, + true, + hasDassTriggered: true, + ); + }, + child: Text( + 'Lihat Ringkasan Sesi Dulu', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _showNormalSessionSummaryBottomSheet( + int durationSeconds, + bool isHighDistress, { + bool hasDassTriggered = false, + }) { + DailyProgressLocalService.recordConversationCheckin(hasConversation: true); + + final tutorialState = ref.read(tutorialNotifierProvider); + final isTutorialActive = !tutorialState.isCompleted && + (tutorialState.currentStep == TutorialStep.voiceCompleted || + tutorialState.currentStep == TutorialStep.voiceStart || + tutorialState.currentStep == TutorialStep.voiceCoins); + + if (isTutorialActive) { + ref + .read(tutorialNotifierProvider.notifier) + .resumeAtStep(TutorialStep.voiceCompleted); + } + + final GlobalKey journalButtonKey = GlobalKey(debugLabel: 'journalDetailButton'); + + void navigateToJournal(BuildContext bottomSheetContext) { + final currentTutorialState = ref.read(tutorialNotifierProvider); + if (!currentTutorialState.isCompleted && + (currentTutorialState.currentStep == TutorialStep.voiceCompleted || + currentTutorialState.currentStep == TutorialStep.voiceStart || + currentTutorialState.currentStep == TutorialStep.voiceCoins)) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceCompleted); + } + Navigator.pop(bottomSheetContext); // Close Bottom Sheet + Navigator.pushNamedAndRemoveUntil( + context, + '/home', + (route) => false, + arguments: {'initialIndex': 1}, + ); + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + isDismissible: !isTutorialActive, + enableDrag: !isTutorialActive, + builder: (bottomSheetContext) { + final Widget sheetCard = Container( + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.check_circle_outline, + color: AppColors.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sesi Suara Selesai 🍃', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Durasi: ${_formatDuration(durationSeconds)} • Hybrid Half-Duplex', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + + // If High Distress: Show top Crisis Support Card + if (isHighDistress) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFFF0F2), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFE57373).withValues(alpha: 0.4), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Color(0xFFFFDCDD), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.favorite, + color: Color(0xFFE57373), + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Intervensi De-eskalasi Disarankan', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFFD32F2F), + ), + ), + Text( + 'Coba teknik napas 4-7-8 untuk menenangkan detak jantung.', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/support'); + }, + child: Text( + 'Buka →', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFFD32F2F), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + ], + + if (hasDassTriggered) ...[ + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: const Color(0xFFE0F4FB), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'SINTESIS OTOMATIS DASS-21', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF20667B), + letterSpacing: 0.5, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + 'EVA telah mengekstrak indikator stres, kecemasan, dan suasana hatimu dari percakapan tadi ke dalam lembar DASS-21 hari ini. Kamu bisa langsung meninjau bukti kutipan obrolan, mengedit skor, atau melengkapi butir yang belum terbahas.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + CustomPillButton( + text: 'Tinjau & Lengkapi Skor DASS-21', + suffixIcon: Icons.arrow_forward_rounded, + onPressed: () { + Navigator.pop(context); // Close Bottom Sheet + Navigator.pushNamed(context, '/dass_assessment'); + }, + ), + const SizedBox(height: 10), + ] else ...[ + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: const Color(0xFFE8F5E9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'REFLEKSI HARIAN TERSIMPAN', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF2E7D32), + letterSpacing: 0.5, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + 'Obrolanmu telah tersimpan dan terangkum rapi ke dalam catatan refleksi emosimu hari ini. Kamu bisa membacanya kembali kapan saja.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + ], + ), + ), + const SizedBox(height: 20), + ], + CustomPillButton( + key: journalButtonKey, + text: 'Lihat Detail Jurnal Refleksi', + isOutline: hasDassTriggered, + onPressed: () => navigateToJournal(bottomSheetContext), + ), + const SizedBox(height: 6), + Center( + child: TextButton( + onPressed: isTutorialActive + ? null + : () { + Navigator.pop(bottomSheetContext); + Navigator.pushNamedAndRemoveUntil( + context, + '/home', + (route) => false, + ); + }, + child: Text( + 'Kembali ke Beranda', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isTutorialActive + ? AppColors.textLight.withValues(alpha: 0.35) + : AppColors.textSecondary, + ), + ), + ), + ), + ], + ), + ); + + if (isTutorialActive) { + return SizedBox( + height: MediaQuery.of(bottomSheetContext).size.height, + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 0, + child: sheetCard, + ), + InteractiveSpotlightOverlay( + targetKey: journalButtonKey, + content: TutorialContent.stepContents[TutorialStep.voiceCompleted]!, + onSkip: () { + ref.read(tutorialNotifierProvider.notifier).skipTutorial(); + Navigator.pop(bottomSheetContext); + }, + onNext: () => navigateToJournal(bottomSheetContext), + onTargetTap: () => navigateToJournal(bottomSheetContext), + ), + ], + ), + ); + } + + return sheetCard; + }, + ); + } + + void _showSettingsBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (modalContext) { + return Consumer( + builder: (context, ref, _) { + final currentState = ref.watch(aiCallControllerProvider); + final currentController = ref.read(aiCallControllerProvider.notifier); + + return Container( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + decoration: const BoxDecoration( + color: Color(0xFF1E1B2E), + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 20), + Text( + 'Pengaturan Panggilan', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + Text( + 'Pilih metode interaksi suara yang paling nyaman untuk Anda.', + style: GoogleFonts.inter( + fontSize: 13, + color: Colors.white60, + ), + ), + const SizedBox(height: 20), + _buildSettingsModeTile( + title: 'Auto Turn (Otomatis)', + subtitle: 'Deteksi jeda ucapan otomatis tanpa perlu menekan tombol.', + isSelected: currentState.conversationMode == ConversationMode.hybridAuto, + onTap: () { + currentController.setConversationMode(ConversationMode.hybridAuto); + Navigator.pop(modalContext); + }, + ), + const SizedBox(height: 12), + _buildSettingsModeTile( + title: 'Push-To-Talk (Manual)', + subtitle: 'Tekan dan tahan tombol saat Anda ingin berbicara ke EVA.', + isSelected: currentState.conversationMode == ConversationMode.pushToTalk, + onTap: () { + currentController.setConversationMode(ConversationMode.pushToTalk); + Navigator.pop(modalContext); + }, + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _buildSettingsModeTile({ + required String title, + required String subtitle, + required bool isSelected, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? AppColors.primary.withValues(alpha: 0.15) + : Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? AppColors.primary : Colors.white10, + width: isSelected ? 1.5 : 1.0, + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 12, + color: Colors.white60, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Icon( + isSelected ? Icons.radio_button_checked : Icons.radio_button_off, + color: isSelected ? AppColors.primary : Colors.white38, + size: 22, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(aiCallControllerProvider); + final controller = ref.read(aiCallControllerProvider.notifier); + + if (!_aiRippleController.isAnimating) { + _aiRippleController.repeat(); + } + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + _handleExitCall(state); + } + }, + child: Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFF13111C), + Color(0xFF0F0E17), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Column( + children: [ + // Header Bar with Back Button, Centered Duration Timer, and Settings Icon + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + children: [ + AppBackButton.dark( + onPressed: () => _handleExitCall(state), + ), + Expanded( + child: Center( + child: Text( + _formatDuration(state.callDurationSeconds), + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white70, + letterSpacing: 0.5, + ), + ), + ), + ), + IconButton( + icon: const Icon(Icons.settings_outlined, color: Colors.white), + onPressed: () => _showSettingsBottomSheet(context), + ), + ], + ), + ), + + const Spacer(), + + // Center Avatar Orb with Multi-Layer Ripple Wave Animation + _buildInteractiveAvatarOrb(state), + + const SizedBox(height: 24), + + // Title & Clean Subtitle Info + Text( + 'EVA AI Assistant', + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + Text( + _getCleanStatusText(state), + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + color: state.isMuted ? const Color(0xFFFF8B94) : Colors.white70, + letterSpacing: 0.2, + ), + ), + + // Dynamic Live Transcript preview + if (state.latestPartial.isNotEmpty || state.currentTranscript.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 8.0), + child: Text( + '"${state.latestPartial.isNotEmpty ? state.latestPartial : state.currentTranscript}"', + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 13, + fontStyle: FontStyle.italic, + color: Colors.white60, + ), + ), + ), + + + + const Spacer(), + + // INTERACTIVE ACTION CONTROLS + // 1. In LISTENING: "Selesai Bicara" (Manual Force Commit) + // 2. In AI_SPEAKING or THINKING: "Bicara (Potong AI)" (Manual Barge-In) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: _buildMainActionButton(state, controller), + ), + + const SizedBox(height: 24), + + // Bottom Control Bar (Mute, End Call, Speaker) + Padding( + padding: const EdgeInsets.fromLTRB(36.0, 0, 36.0, 32.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Mute Microphone Button + GestureDetector( + onTap: () => controller.toggleMute(), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: state.isMuted + ? const Color(0xFFE53935) + : Colors.white.withValues(alpha: 0.15), + ), + child: Icon( + state.isMuted ? Icons.mic_off : Icons.mic, + color: Colors.white, + size: 26, + ), + ), + ), + + // End Call Button + GestureDetector( + onTap: () => _handleExitCall(state), + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFD32F2F), + ), + child: const Icon( + Icons.call_end, + color: Colors.white, + size: 30, + ), + ), + ), + + // Speakerphone Toggle Button + GestureDetector( + onTap: () => controller.toggleSpeaker(), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: state.isSpeakerOn + ? const Color(0xFF3F51B5) + : Colors.white.withValues(alpha: 0.15), + ), + child: Icon( + state.isSpeakerOn ? Icons.volume_up_rounded : Icons.volume_down_rounded, + color: Colors.white, + size: 26, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); +} + + + + Widget _buildMainActionButton(AiCallViewState state, AiCallController controller) { + if (state.conversationMode == ConversationMode.pushToTalk) { + return GestureDetector( + onTapDown: (_) => controller.pushToTalkPress(), + onTapUp: (_) => controller.pushToTalkRelease(), + onTapCancel: () => controller.pushToTalkRelease(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + decoration: BoxDecoration( + color: state.callState == CallState.listening + ? const Color(0xFFE53935) + : AppColors.primary, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.touch_app, color: Colors.white, size: 20), + const SizedBox(width: 8), + Text( + 'Tahan untuk Bicara', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } + + // Hybrid Auto Mode Dynamic Action Button + if (state.callState == CallState.aiSpeaking) { + // Barge-in / Jeda AI button (Simple & elegant, matching Selesai Bicara style) + return ElevatedButton.icon( + onPressed: () => controller.bargeIn(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF59E0B), + foregroundColor: Colors.white, + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + side: BorderSide( + color: Colors.white.withValues(alpha: 0.25), + width: 1, + ), + ), + ), + icon: const Icon(Icons.pause_rounded, color: Colors.white, size: 20), + label: Text( + 'Jeda AI', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 0.2, + ), + ), + ); + } else if (state.callState == CallState.thinking) { + // Non-interactive thinking badge to prevent accidental tap cancellation + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary), + ), + const SizedBox(width: 10), + Text( + 'Eva sedang memproses...', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white70, + ), + ), + ], + ), + ); + } else if (state.callState == CallState.listening && + (state.currentTranscript.isNotEmpty || state.latestPartial.isNotEmpty)) { + // Force Commit button (Only when user has actually spoken something) + return ElevatedButton.icon( + onPressed: () => controller.forceCommit(), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + ), + icon: const Icon(Icons.send_rounded, color: Colors.white, size: 18), + label: Text( + 'Selesai Bicara', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ); + } + + return const SizedBox(height: 48); + } + + String _getCleanStatusText(AiCallViewState state) { + if (state.isMuted) return 'Mikrofon Di-Mute'; + + switch (state.callState) { + case CallState.listening: + return state.isUserSpeaking + ? 'Mendengarkan (Anda sedang berbicara)' + : 'Mendengarkan...'; + case CallState.thinking: + return 'AI Memproses...'; + case CallState.aiSpeaking: + return 'EVA Sedang Berbicara...'; + case CallState.interrupting: + return 'Memotong Suara EVA...'; + case CallState.idle: + case CallState.ended: + case CallState.error: + return state.errorMessage ?? 'Standby'; + } + } + + Widget _buildInteractiveAvatarOrb(AiCallViewState state) { + final bool isAiSpeaking = state.callState == CallState.aiSpeaking; + final double soundLevel = state.isMuted ? 0.0 : state.soundLevel; + + return RepaintBoundary( + child: AnimatedBuilder( + animation: _aiRippleController, + builder: (context, child) { + final animValue = _aiRippleController.value; + + return Center( + child: SizedBox( + width: 280, + height: 280, + child: Stack( + alignment: Alignment.center, + children: [ + // 1. RADIAL FREQUENCY EQUALIZER BARS (CIRCULAR AUDIO SPECTRUM) + CustomPaint( + size: const Size(280, 280), + painter: RadialFrequencyBarsPainter( + animationProgress: animValue, + soundLevel: soundLevel, + isAiSpeaking: isAiSpeaking, + isMuted: state.isMuted, + ), + ), + + // 2. SOFT AMBIENT IRIDESCENT HALO + Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFFC7D2FE).withValues( + alpha: state.isMuted ? 0.08 : 0.20 + ((isAiSpeaking ? soundLevel : 0.0) * 0.25), + ), + blurRadius: 36 + ((isAiSpeaking ? soundLevel : 0.0) * 20), + spreadRadius: 3, + ), + BoxShadow( + color: const Color(0xFFFFD1DC).withValues( + alpha: state.isMuted ? 0.05 : 0.15 + ((isAiSpeaking ? soundLevel : 0.0) * 0.15), + ), + blurRadius: 28, + spreadRadius: 2, + ), + ], + ), + ), + + // 3. MAIN ORB (PASTEL / IRIDESCENT SPHERE) + Container( + width: 174, + height: 174, + decoration: BoxDecoration( + shape: BoxShape.circle, + // Soft iridescent pastel gradient exactly like the design + gradient: const RadialGradient( + center: Alignment(-0.25, -0.35), + radius: 0.95, + colors: [ + Color(0xFFFFFFFF), // soft white highlight + Color(0xFFEDE9FE), // pale lavender + Color(0xFFE0E7FF), // soft periwinkle + Color(0xFFBAE6FD), // soft cyan edge + ], + stops: [0.0, 0.45, 0.75, 1.0], + ), + border: Border.all( + // Thin subtle iridescent outer border + color: const Color(0xFFE2E8F0).withValues(alpha: 0.7), + width: 1.5, + ), + boxShadow: [ + // Subtle rim illumination + BoxShadow( + color: const Color(0xFF818CF8).withValues(alpha: 0.25), + blurRadius: 16, + spreadRadius: 1, + ), + BoxShadow( + color: const Color(0xFFF472B6).withValues(alpha: 0.20), + blurRadius: 20, + spreadRadius: -2, + offset: const Offset(4, -2), + ), + ], + ), + child: Center( + // 4. UNIFIED MORPHING VOICE NODES VISUALIZER + // Seamless pure code transformation: + // Listening / User Speaking / AI Speaking: Equalizer bars / flat dots + // Thinking: Nodes morph, expand, orbit in circle with glowing aurora colors, and return smoothly. + child: MorphingVoiceNodesVisualizer( + callState: state.callState, + soundLevel: soundLevel, + isAiSpeaking: isAiSpeaking, + animValue: animValue, + isMuted: state.isMuted, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); +} +} diff --git a/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_session_detail_screen.dart b/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_session_detail_screen.dart new file mode 100644 index 0000000..4c24904 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_session_detail_screen.dart @@ -0,0 +1,766 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/chat/presentation/providers/chat_provider.dart'; +import 'package:eva_mobile/features/diary/presentation/providers/diary_provider.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class VoiceSessionDetailScreen extends ConsumerStatefulWidget { + final Map<String, dynamic>? sessionData; + + const VoiceSessionDetailScreen({ + super.key, + this.sessionData, + }); + + @override + ConsumerState<VoiceSessionDetailScreen> createState() => _VoiceSessionDetailScreenState(); +} + +class _VoiceSessionDetailScreenState extends ConsumerState<VoiceSessionDetailScreen> { + Map<String, dynamic>? _sessionData; + Timer? _pollingTimer; + int _pollCount = 0; + int _infiniteLimit = 10; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _sessionData = widget.sessionData != null ? Map<String, dynamic>.from(widget.sessionData!) : null; + _checkAndStartPolling(); + _scrollController.addListener(_onScroll); + } + + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 150) { + final rawTranscript = _sessionData?['transcript'] as List? ?? []; + if (_infiniteLimit < rawTranscript.length) { + setState(() { + _infiniteLimit += 10; + }); + } + } + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + _pollingTimer?.cancel(); + super.dispose(); + } + + bool _isTitleLoading(String title) { + return title.isEmpty || + title.contains(r'$skeleton') || + _sessionData?['is_title_generating'] == true; + } + + void _checkAndStartPolling() { + final rawTitle = _sessionData?['title']?.toString() ?? ''; + if (_isTitleLoading(rawTitle)) { + _pollCount = 0; + _pollingTimer?.cancel(); + _pollingTimer = Timer.periodic(const Duration(milliseconds: 2500), (timer) async { + _pollCount++; + if (_pollCount > 8) { + timer.cancel(); + return; + } + await _pollUpdatedSessionData(); + }); + } + } + + Future<void> _pollUpdatedSessionData() async { + final sessionId = _sessionData?['id']?.toString(); + if (sessionId != null && sessionId.isNotEmpty) { + final chatRepo = ref.read(chatRepositoryProvider); + final updated = await chatRepo.getConversationById(sessionId); + if (updated != null && mounted) { + final newTitle = updated.title; + setState(() { + _sessionData?['title'] = newTitle; + if (updated.summary.isNotEmpty) { + _sessionData?['summary'] = updated.summary; + } + if (updated.messages.isNotEmpty) { + final raw = _sessionData?['transcript']; + if (raw == null || (raw is List && raw.isEmpty)) { + _sessionData?['transcript'] = updated.messages + .map((m) => { + 'role': m.isUser ? 'user' : 'assistant', + 'content': m.text, + 'time': m.time, + 'emotionTag': m.emotionTag, + 'emotionEmoji': m.emotionEmoji, + }) + .toList(); + } + } + }); + + if (!_isTitleLoading(newTitle)) { + _pollingTimer?.cancel(); + _pollingTimer = null; + } + return; + } + + final conversations = await chatRepo.getConversations(); + final match = conversations.where((it) => it.id == sessionId).firstOrNull; + if (match != null && mounted) { + final newTitle = match.title; + setState(() { + _sessionData?['title'] = newTitle; + if (match.summary.isNotEmpty) { + _sessionData?['summary'] = match.summary; + } + }); + if (!_isTitleLoading(newTitle)) { + _pollingTimer?.cancel(); + _pollingTimer = null; + } + } + } + } + + static Color _parseHexColor(dynamic input) { + if (input is Color) return input; + if (input is String) { + String hex = input.replaceAll('#', '').trim(); + if (hex.length == 6) hex = 'FF$hex'; + final val = int.tryParse(hex, radix: 16); + if (val != null) return Color(val); + } + return const Color(0xFF6C63FF); + } + + Widget _buildTitleSkeleton() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 200, + height: 18, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(height: 8), + Container( + width: 130, + height: 12, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final rawTitle = _sessionData?['title']?.toString() ?? ''; + final bool isTitleLoading = _isTitleLoading(rawTitle); + final displayTitle = rawTitle.replaceAll(r'$skeleton', '').trim(); + final title = displayTitle.isEmpty ? 'Sesi Panggilan Suara' : displayTitle; + final date = _sessionData?['date']?.toString() ?? 'Hari ini, 09:15 AM'; + final duration = _sessionData?['duration']?.toString() ?? '04:12'; + + final emotionAnalysisMap = _sessionData?['emotion_analysis'] as Map<String, dynamic>?; + final rawEmotionList = (_sessionData?['emotions_breakdown'] ?? emotionAnalysisMap?['emotions_breakdown']) as List? ?? [ + {'label': 'Ketenangan & Kedamaian', 'emoji': '😌', 'percent': 0.85, 'color': '#4ECDC4'}, + {'label': 'Bahagia & Puas', 'emoji': '😃', 'percent': 0.60, 'color': '#FFE6A7'}, + {'label': 'Takut & Gelisah', 'emoji': '😨', 'percent': 0.25, 'color': '#6C63FF'}, + {'label': 'Tingkat Stres', 'emoji': '😟', 'percent': 0.15, 'color': '#FF8B94'}, + ]; + + final emotionsBreakdown = rawEmotionList.map((item) { + final map = Map<String, dynamic>.from(item as Map); + return { + 'label': map['label']?.toString() ?? 'Emosi', + 'emoji': map['emoji']?.toString() ?? '😊', + 'percent': (map['percent'] as num?)?.toDouble() ?? 0.5, + 'color': _parseHexColor(map['color']), + }; + }).toList(); + + final rawTranscript = _sessionData?['transcript'] as List? ?? [ + { + 'role': 'user', + 'content': 'Halo Eva, aku merasa cemas sekali dengan beban pekerjaanku hari ini.', + 'time': '09:15 AM', + 'emotionTag': 'fear (68%)', + 'emotionEmoji': '😨', + }, + { + 'role': 'assistant', + 'content': 'Halo! Aku di sini mendengarkanmu. Ceritakan padaku apa yang membuatmu merasa cemas hari ini?', + 'time': '09:15 AM', + }, + ]; + + final transcriptList = rawTranscript.map((item) { + final map = Map<String, dynamic>.from(item as Map); + return { + 'role': map['role']?.toString() ?? 'user', + 'content': map['content']?.toString() ?? '', + 'time': map['time']?.toString() ?? '09:15 AM', + 'emotionTag': map['emotionTag']?.toString(), + 'emotionEmoji': map['emotionEmoji']?.toString(), + }; + }).toList(); + + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Column( + children: [ + // Header Bar Navigation + StaggeredEntrance( + index: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Detail Sesi Suara', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + ), + + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), + child: Column( + children: [ + // Header Card: Title (with Skeleton Loading support), Date, Duration Badge + StaggeredEntrance( + index: 1, + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.volume_up_rounded, + color: AppColors.primary, + size: 26, + ), + ), + const SizedBox(width: 14), + Expanded( + child: isTitleLoading + ? _buildTitleSkeleton() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + '$date • $duration', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 20), + + // 1. ANALISIS PARAMETER EMOSI (Progress Chart Section) + StaggeredEntrance( + index: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.analytics_outlined, + color: AppColors.primary, + size: 18, + ), + ), + const SizedBox(width: 10), + Text( + 'ANALISIS PARAMETER EMOSI', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 10), + + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + children: [ + ...emotionsBreakdown.map((emo) { + final double pct = (emo['percent'] as double).clamp(0.0, 1.0); + final int pctInt = (pct * 100).round(); + return Padding( + padding: const EdgeInsets.only(bottom: 14.0), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text(emo['emoji'].toString(), style: const TextStyle(fontSize: 16)), + const SizedBox(width: 8), + Text( + emo['label'].toString(), + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + Text( + '$pctInt%', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: pct, + minHeight: 8, + backgroundColor: const Color(0xFFE2E4F0), + valueColor: AlwaysStoppedAnimation<Color>(emo['color'] as Color), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 14), + + // Tombol Lihat Jurnal Harian AI (dengan State Loading vs Ready) + StaggeredEntrance( + index: 3, + child: SizedBox( + width: double.infinity, + child: isTitleLoading + ? Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withAlpha(120), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.primary.withAlpha(50)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2.2, + valueColor: AlwaysStoppedAnimation<Color>(AppColors.primary), + ), + ), + const SizedBox(width: 12), + Text( + 'Jurnal Harian Sedang Dibuat...', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ) + : ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + onPressed: () async { + try { + final diaryRepo = ref.read(diaryRepositoryProvider); + final entry = await diaryRepo.getTodayDiary() ?? await diaryRepo.generateTodayDiary(); + + if (context.mounted) { + final Map<String, dynamic> diaryArgs = entry != null + ? { + 'id': entry.id, + 'title': entry.title, + 'date': entry.date, + 'sessionCount': entry.sessionCount, + 'lastSessionTime': entry.lastSessionTime, + 'moodTag': entry.moodTag, + 'moodEmoji': entry.moodEmoji, + 'summary': entry.summary, + 'riskWarning': entry.riskWarning != null + ? { + 'detected': entry.riskWarning!.detected, + 'type': entry.riskWarning!.type, + 'title': entry.riskWarning!.title, + 'level': entry.riskWarning!.level, + 'message': entry.riskWarning!.message, + } + : null, + 'aiInsight': entry.aiInsight, + 'importantEvents': entry.importantEvents, + 'emotionalReflection': entry.emotionalReflection, + 'sessions': entry.sessions + .map((s) => { + 'id': s.id, + 'title': s.title, + 'time': s.time, + 'moodTag': s.moodTag, + 'moodEmoji': s.moodEmoji, + }) + .toList(), + } + : { + 'id': 'today_default', + 'title': 'Refleksi Harian EVA', + 'date': 'Hari ini', + 'sessionCount': 1, + 'lastSessionTime': '09:15 AM', + 'moodTag': 'Tenang 🌿', + 'moodEmoji': '🌿', + 'summary': 'Sesi percakapan harian berhasil disintesis oleh EVA AI.', + 'aiInsight': 'Pengguna merasa lebih lega setelah berdialog dengan EVA AI.', + 'emotionalReflection': 'Respon emosional terpantau positif dan stabil.', + 'importantEvents': ['[Hari ini] Percakapan Suara EVA AI'], + 'sessions': [], + }; + + Navigator.pushNamed(context, '/diary_detail', arguments: diaryArgs); + } + } catch (e, stack) { + debugPrint('⚠️ [DIARY DETAIL NAV EXCEPTION]: $e\n$stack'); + if (context.mounted) { + final fallbackArgs = { + 'id': 'today_fallback', + 'title': 'Jurnal Harian EVA AI', + 'date': 'Hari ini', + 'sessionCount': 1, + 'lastSessionTime': 'Hari ini', + 'moodTag': 'Tenang 🌿', + 'moodEmoji': '🌿', + 'summary': 'Sesi percakapan harian berhasil disintesis oleh EVA AI.', + 'aiInsight': 'Pengguna merasa lebih lega setelah berdialog dengan EVA AI.', + 'emotionalReflection': 'Respon emosional terpantau positif dan stabil.', + 'importantEvents': ['[Hari ini] Sesi Percakapan Suara EVA AI'], + 'sessions': [], + }; + Navigator.pushNamed(context, '/diary_detail', arguments: fallbackArgs); + } + } + }, + icon: const Icon(Icons.auto_awesome, size: 18), + label: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Lihat Jurnal Harian AI Hari Ini', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + const Icon(Icons.arrow_forward_rounded, size: 18), + ], + ), + ), + ), + ), + + const SizedBox(height: 24), + + // 2. TRANSKRIP PERCAKAPAN SUARA (Diary Style) + StaggeredEntrance( + index: 4, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFFE0F4FB), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.chat_bubble_outline_rounded, + color: Color(0xFF20667B), + size: 18, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + 'TRANSKRIP SUARA', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w800, + color: const Color(0xFF20667B), + letterSpacing: 0.8, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + '${transcriptList.length} Pesan', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 12), + + Builder( + builder: (context) { + final int totalCount = transcriptList.length; + final int displayCount = totalCount.clamp(0, _infiniteLimit); + final pageItems = transcriptList.sublist(0, displayCount); + + return Column( + children: [ + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: pageItems.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final item = pageItems[index]; + final isUser = item['role'] == 'user' || item['isUser'].toString() == 'true'; + final String speakerLabel = isUser ? 'Pengguna' : 'EVA AI'; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isUser ? const Color(0xFFF0F2FF) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isUser + ? AppColors.primary.withValues(alpha: 0.25) + : Colors.grey.shade200, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(6), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isUser ? AppColors.primaryContainer : const Color(0xFFE0F4FB), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + speakerLabel, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: isUser ? AppColors.primary : const Color(0xFF20667B), + ), + ), + ), + if (isUser && item['emotionTag'] != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFFF0F0), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${item['emotionEmoji'] ?? '😟'} ${item['emotionTag']}', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFFD32F2F), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + item['content'] ?? item['text'] ?? '', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + if (item['time'] != null && item['time'].toString().isNotEmpty) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.bottomRight, + child: Text( + item['time']?.toString() ?? '', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textLight, + ), + ), + ), + ], + ], + ), + ); + }, + ), + + if (_infiniteLimit < totalCount) ...[ + const SizedBox(height: 16), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text( + 'Memuat percakapan berikutnya...', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + ], + ], + ); + }, + ), + ], + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/voice_call/presentation/widgets/morphing_voice_nodes_visualizer.dart b/apps/eva_mobile/lib/features/voice_call/presentation/widgets/morphing_voice_nodes_visualizer.dart new file mode 100644 index 0000000..0d6ab6e --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/presentation/widgets/morphing_voice_nodes_visualizer.dart @@ -0,0 +1,502 @@ +import "dart:math" as math; +import "package:flutter/material.dart"; + +import "../../domain/entities/call_state.dart"; + +class MorphingVoiceNodesVisualizer extends StatefulWidget { + final CallState callState; + final double soundLevel; + final bool isAiSpeaking; + final double animValue; + final bool isMuted; + + const MorphingVoiceNodesVisualizer({ + super.key, + required this.callState, + required this.soundLevel, + required this.isAiSpeaking, + required this.animValue, + required this.isMuted, + }); + + @override + State<MorphingVoiceNodesVisualizer> createState() => MorphingVoiceNodesVisualizerState(); +} + +class MorphingVoiceNodesVisualizerState extends State<MorphingVoiceNodesVisualizer> + with SingleTickerProviderStateMixin { + late final AnimationController _morphController; + late final Animation<double> _morphAnimation; + + static const double _dotSize = 5.0; + static const List<double> _minHeights = [8.0, 12.0, 16.0, 12.0, 8.0]; + static const List<double> _maxHeights = [26.0, 42.0, 58.0, 42.0, 26.0]; + + final List<double> _currentHeights = [_dotSize, _dotSize, _dotSize, _dotSize, _dotSize]; + + @override + void initState() { + super.initState(); + _morphController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 550), + ); + _morphAnimation = CurvedAnimation( + parent: _morphController, + curve: Curves.easeInOutCubic, + ); + + if (widget.callState == CallState.thinking) { + _morphController.value = 1.0; + } + } + + @override + void didUpdateWidget(covariant MorphingVoiceNodesVisualizer oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.callState == CallState.thinking && oldWidget.callState != CallState.thinking) { + _morphController.forward(); + } else if (widget.callState != CallState.thinking && oldWidget.callState == CallState.thinking) { + _morphController.reverse(); + } + + _updatePhysics(); + } + + void _updatePhysics() { + final double sound = widget.isMuted ? 0.0 : widget.soundLevel; + final bool hasAudio = sound > 0.03 || (widget.isAiSpeaking && sound > 0.02); + + for (int i = 0; i < 5; i++) { + double targetHeight; + + if (widget.callState == CallState.thinking) { + // In thinking mode: nodes become circular dots + targetHeight = _dotSize; + } else if (!hasAudio || widget.isMuted) { + // Silence / Idle: Flat dots + targetHeight = _dotSize; + } else if (widget.isAiSpeaking) { + // AI Speaking: Dynamic vocal formant distribution & intonation contours + // 5 nodes represent frequency bands across speech spectrum: + // Node 0: Chest resonance / low fundamental (100-250 Hz) + // Node 1: Pitch inflection / F0 contour (glide with Indonesian sentence intonation) + // Node 2: First Formant F1 / Vowel energy (500-1000 Hz, peak acoustic power) + // Node 3: Second Formant F2 / Articulation transitions (1.5-2.5 kHz) + // Node 4: Sibilance / High treble energy (3-5 kHz) + final double anim = widget.animValue; + final double band0 = sound * (0.85 + 0.20 * math.sin(anim * 2 * math.pi * 3.5)); + final double band1 = sound * (1.15 + 0.28 * math.sin(anim * 2 * math.pi * 5.2 + 0.8)); + final double band2 = sound * (1.45 + 0.35 * math.sin(anim * 2 * math.pi * 7.0 + 1.6)); + final double band3 = sound * (1.15 + 0.26 * math.sin(anim * 2 * math.pi * 6.1 + 2.4)); + final double band4 = sound * (0.80 + 0.22 * math.sin(anim * 2 * math.pi * 8.4 + 3.2)); + + final bands = [band0, band1, band2, band3, band4]; + final intonationScale = bands[i].clamp(0.0, 1.0); + + targetHeight = sound > 0.02 + ? _minHeights[i] + (intonationScale * (_maxHeights[i] - _minHeights[i])) + : _dotSize; + } else { + // User speech + final multipliers = [0.75, 1.1, 1.35, 1.05, 0.7]; + final variance = math.sin(widget.animValue * 2 * math.pi * 6.0 + (i * 1.5)) * 0.15; + final intonationScale = (sound * multipliers[i] + variance).clamp(0.0, 1.0); + targetHeight = _minHeights[i] + (intonationScale * (_maxHeights[i] - _minHeights[i])); + } + + final isRising = targetHeight > _currentHeights[i]; + final factor = isRising + ? (widget.isAiSpeaking ? 0.60 : 0.45) + : (widget.isAiSpeaking ? 0.22 : 0.25); + _currentHeights[i] += (targetHeight - _currentHeights[i]) * factor; + } + } + + @override + void dispose() { + _morphController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _morphController, + builder: (context, child) { + final morphValue = _morphAnimation.value; + + return SizedBox( + width: 174, + height: 174, + child: ClipOval( + child: RepaintBoundary( + child: CustomPaint( + size: const Size(174, 174), + painter: _VoiceNodesMorphPainter( + morphProgress: morphValue, + animProgress: widget.animValue, + barHeights: List<double>.from(_currentHeights), + isThinking: widget.callState == CallState.thinking, + isMuted: widget.isMuted, + ), + ), + ), + ), + ); + }, + ); + } +} + +/// Profile containing harmonized color tokens for each of the 5 nodes, +/// tailored to Eva AI's pastel/periwinkle/lavender brand design system. +class _NodeColorProfile { + final Color core; // Saturated core tone + final Color highlight; // Lighter glowing highlight tone + final Color ambient; // Soft luminous dispersion tint + const _NodeColorProfile(this.core, this.highlight, this.ambient); +} + +/// CustomPainter that renders continuous morphing of the 5 nodes into an organic, +/// abstract swirling fluid gradient ball (inspired by flow_thinking.gif). +class _VoiceNodesMorphPainter extends CustomPainter { + final double morphProgress; + final double animProgress; + final List<double> barHeights; + final bool isThinking; + final bool isMuted; + + _VoiceNodesMorphPainter({ + required this.morphProgress, + required this.animProgress, + required this.barHeights, + required this.isThinking, + required this.isMuted, + }); + + // 5 Color profiles aligned with Eva AI brand & the abstract fluid palette: + // Node 0 (Left): Sky Cyan (Eva Tertiary) + // Node 1 (Mid-Left): Electric Periwinkle (Eva Primary) + // Node 2 (Center): Deep Violet Orchid + // Node 3 (Mid-Right): Radiant Fuchsia Pink + // Node 4 (Right): Sunset Coral Rose + static const List<_NodeColorProfile> _profiles = [ + _NodeColorProfile(Color(0xFF0284C7), Color(0xFF38BDF8), Color(0xFFA7E6FF)), + _NodeColorProfile(Color(0xFF4F46E5), Color(0xFF6366F1), Color(0xFFC7D2FE)), + _NodeColorProfile(Color(0xFF7E22CE), Color(0xFFA855F7), Color(0xFFE9D5FF)), + _NodeColorProfile(Color(0xFFDB2777), Color(0xFFF472B6), Color(0xFFFCE7F3)), + _NodeColorProfile(Color(0xFFE11D48), Color(0xFFFB7185), Color(0xFFFFE4E6)), + ]; + + static const Color _defaultIndigo = Color(0xFF5358CB); // Eva Primary + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + const int nodeCount = 5; + const double barWidth = 4.8; + const double lineSpacing = 10.5; + + // 1. FLUID ABSTRACT BACKGROUND BLEND (ACTIVE DURING THINKING) + if (morphProgress > 0.01 && !isMuted) { + // Soft atmospheric backdrop within the sphere (contained center spread) + final backdropPaint = Paint() + ..color = const Color(0xFFEDE9FE).withValues(alpha: 0.35 * morphProgress) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 16.0); + canvas.drawCircle(center, 44.0 * morphProgress, backdropPaint); + + // Render the 5 expanding organic fluid lobes + for (int i = 0; i < nodeCount; i++) { + final profile = _profiles[i]; + // 1.0x integer revolution ensures 100% seamless continuity when loop repeats + final double theta = (i * 2 * math.pi / nodeCount) + (animProgress * 2 * math.pi * 1.0); + + // Controlled Lissajous displacement with integer harmonic cycles + final double lissajousX = math.cos(theta) * (18.0 + math.sin(animProgress * 2 * math.pi * 1.0 + i) * 4.0); + final double lissajousY = math.sin(theta) * (16.0 + math.cos(animProgress * 2 * math.pi * 1.0 - i) * 4.0); + final Offset linePos = Offset(center.dx + (i - 2) * lineSpacing, center.dy); + final Offset orbitPos = center + Offset(lissajousX, lissajousY); + final Offset lobeCenter = Offset.lerp(linePos, orbitPos, morphProgress)!; + + // Controlled fluid radius with 2.0x integer harmonic breathing wave + final double baseRadius = Tween<double>(begin: 3.0, end: 38.0).transform(morphProgress); + final double breathing = math.sin((animProgress * 2 * math.pi * 2.0) + (i * 1.3)) * 4.5 * morphProgress; + final double lobeRadius = baseRadius + breathing; + + // Generate undulating organic fluid path (harmonic contour with integer wave cycles) + final Path lobePath = Path(); + const int vertices = 16; + for (int v = 0; v <= vertices; v++) { + final double angle = (v / vertices) * 2 * math.pi; + final double wave1 = math.sin((angle * 2.0) + (animProgress * 2 * math.pi * 2.0) + i) * 0.16; + final double wave2 = math.cos((angle * 3.0) - (animProgress * 2 * math.pi * 1.0) + (i * 0.8)) * 0.10; + final double r = lobeRadius * (1.0 + (wave1 + wave2) * morphProgress); + final double vx = lobeCenter.dx + r * math.cos(angle); + final double vy = lobeCenter.dy + r * math.sin(angle); + if (v == 0) { + lobePath.moveTo(vx, vy); + } else { + lobePath.lineTo(vx, vy); + } + } + lobePath.close(); + + // Multi-stop radial gradient for silky fluid density + final lobePaint = Paint() + ..shader = RadialGradient( + center: Alignment( + -0.20 * math.cos(theta), + -0.20 * math.sin(theta), + ), + radius: 0.90, + colors: [ + profile.highlight.withValues(alpha: 0.82 * morphProgress), + profile.core.withValues(alpha: 0.62 * morphProgress), + profile.ambient.withValues(alpha: 0.22 * morphProgress), + profile.core.withValues(alpha: 0.0), + ], + stops: const [0.0, 0.42, 0.75, 1.0], + ).createShader(Rect.fromCircle(center: lobeCenter, radius: lobeRadius * 1.2)) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 10.0 * morphProgress); + + canvas.drawPath(lobePath, lobePaint); + } + + // 2. CURVING FLUID SILK RIBBONS (with 1.0x integer revolution and synchronous sweep) + final ribbonProgress = (morphProgress - 0.15).clamp(0.0, 0.85) / 0.85; + if (ribbonProgress > 0.02) { + final double ribbonAngle = animProgress * 2 * math.pi * 1.0; + final Path ribbon1 = Path(); + ribbon1.moveTo( + center.dx + 42 * math.cos(ribbonAngle), + center.dy + 42 * math.sin(ribbonAngle), + ); + ribbon1.quadraticBezierTo( + center.dx + 16 * math.cos(ribbonAngle + 1.8), + center.dy + 16 * math.sin(ribbonAngle + 1.8), + center.dx + 42 * math.cos(ribbonAngle + math.pi), + center.dy + 42 * math.sin(ribbonAngle + math.pi), + ); + + final ribbonPaint1 = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 9.0 * ribbonProgress + ..strokeCap = StrokeCap.round + ..shader = SweepGradient( + center: Alignment.center, + startAngle: ribbonAngle, + endAngle: ribbonAngle + (2 * math.pi), + colors: [ + _profiles[0].highlight.withValues(alpha: 0.45 * ribbonProgress), + _profiles[2].highlight.withValues(alpha: 0.45 * ribbonProgress), + _profiles[3].highlight.withValues(alpha: 0.40 * ribbonProgress), + _profiles[0].highlight.withValues(alpha: 0.45 * ribbonProgress), + ], + ).createShader(Rect.fromCircle(center: center, radius: 46.0)) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 6.0 * ribbonProgress); + + canvas.drawPath(ribbon1, ribbonPaint1); + } + + // 3. SPECULAR GLASS GLARE OVERLAY + final glarePaint = Paint() + ..shader = RadialGradient( + center: const Alignment(-0.35, -0.45), + radius: 0.70, + colors: [ + Colors.white.withValues(alpha: 0.35 * morphProgress), + Colors.white.withValues(alpha: 0.08 * morphProgress), + Colors.white.withValues(alpha: 0.0), + ], + stops: const [0.0, 0.45, 1.0], + ).createShader(Rect.fromCircle(center: center, radius: 54.0)); + canvas.drawCircle(center, 52.0, glarePaint); + } + + // 4. THE 5 EQUALIZER NODES (SMOOTHLY FADE OUT DURING THINKING FLUID STATE) + final double nodeOpacity = (1.0 - (morphProgress * 1.4)).clamp(0.0, 1.0); + if (nodeOpacity > 0.01) { + for (int i = 0; i < nodeCount; i++) { + final double lineX = center.dx + (i - 2) * lineSpacing; + final double lineY = center.dy; + final currentCenter = Offset(lineX, lineY); + + final double targetHeight = barHeights[i]; + final Color nodeColor = isMuted + ? const Color(0xFF94A3B8).withValues(alpha: nodeOpacity) + : _defaultIndigo.withValues(alpha: nodeOpacity); + + final rRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: currentCenter, + width: barWidth, + height: targetHeight.clamp(barWidth, 60.0), + ), + const Radius.circular(999), + ); + + final nodePaint = Paint() + ..color = nodeColor + ..style = PaintingStyle.fill; + + canvas.drawRRect(rRect, nodePaint); + } + } + } + + @override + bool shouldRepaint(covariant _VoiceNodesMorphPainter oldDelegate) { + if (oldDelegate.morphProgress != morphProgress || + oldDelegate.animProgress != animProgress || + oldDelegate.isThinking != isThinking || + oldDelegate.isMuted != isMuted) { + return true; + } + if (oldDelegate.barHeights.length != barHeights.length) return true; + for (int i = 0; i < barHeights.length; i++) { + if ((oldDelegate.barHeights[i] - barHeights[i]).abs() > 0.1) { + return true; + } + } + return false; + } +} + +/// CustomPainter that renders radial frequency audio equalizer bars around the orb perimeter, +/// matching the exact visual style from the reference video (istockphoto-2265409964). +/// When silent, all radial bars shrink to zero / tiny baseline dots. +class RadialFrequencyBarsPainter extends CustomPainter { + final double animationProgress; + final double soundLevel; + final bool isAiSpeaking; + final bool isMuted; + + RadialFrequencyBarsPainter({ + required this.animationProgress, + required this.soundLevel, + required this.isAiSpeaking, + required this.isMuted, + }); + + @override + void paint(Canvas canvas, Size size) { + if (isMuted || !isAiSpeaking) return; + + final center = Offset(size.width / 2, size.height / 2); + const baseRadius = 89.0; // Starts right at outer perimeter of the 174px orb + const totalPoints = 72; // High resolution points around 360 degrees for silky curves + + final double masterIntensity = (soundLevel * 1.15).clamp(0.0, 1.0); + if (masterIntensity <= 0.01) return; + + final points = <Offset>[]; + final innerEchoPoints = <Offset>[]; + + for (int i = 0; i < totalPoints; i++) { + final double theta = (i / totalPoints) * 2 * math.pi; + + final double harmonicCluster1 = math.sin(theta * 2.0 - 0.5); + final double harmonicCluster2 = math.cos(theta * 3.0 + 1.2); + final double clusterFactor = ((harmonicCluster1 * 0.7 + harmonicCluster2 * 0.3).abs()).clamp(0.0, 1.0); + + final double freqJitter = ((math.sin((i * 7.7) + (animationProgress * 2 * math.pi * 5.0)) + 1.0) / 2.0); + + final double spikeFactor = math.pow(clusterFactor, 2.2).toDouble(); + final double dynamicHeight = (masterIntensity * 42.0 * (0.25 + (spikeFactor * 0.75))) * (0.6 + freqJitter * 0.4); + + final double barLength = 2.0 + dynamicHeight; + final double currentRadius = baseRadius + barLength; + + final double endX = center.dx + currentRadius * math.cos(theta); + final double endY = center.dy + currentRadius * math.sin(theta); + points.add(Offset(endX, endY)); + + // Subtle inner harmonic wave for depth + final double innerRadius = baseRadius + (barLength * 0.5); + final double innerX = center.dx + innerRadius * math.cos(theta); + final double innerY = center.dy + innerRadius * math.sin(theta); + innerEchoPoints.add(Offset(innerX, innerY)); + } + + // 1. Build Outer Wavy Path (Smooth closed quadratic bezier) + final outerPath = Path(); + final n = points.length; + final firstMid = Offset( + (points[n - 1].dx + points[0].dx) / 2, + (points[n - 1].dy + points[0].dy) / 2, + ); + outerPath.moveTo(firstMid.dx, firstMid.dy); + + for (int i = 0; i < n; i++) { + final next = points[(i + 1) % n]; + final mid = Offset( + (points[i].dx + next.dx) / 2, + (points[i].dy + next.dy) / 2, + ); + outerPath.quadraticBezierTo(points[i].dx, points[i].dy, mid.dx, mid.dy); + } + outerPath.close(); + + // 2. Build Inner Echo Wavy Path + final innerPath = Path(); + final innerFirstMid = Offset( + (innerEchoPoints[n - 1].dx + innerEchoPoints[0].dx) / 2, + (innerEchoPoints[n - 1].dy + innerEchoPoints[0].dy) / 2, + ); + innerPath.moveTo(innerFirstMid.dx, innerFirstMid.dy); + + for (int i = 0; i < n; i++) { + final next = innerEchoPoints[(i + 1) % n]; + final mid = Offset( + (innerEchoPoints[i].dx + next.dx) / 2, + (innerEchoPoints[i].dy + next.dy) / 2, + ); + innerPath.quadraticBezierTo(innerEchoPoints[i].dx, innerEchoPoints[i].dy, mid.dx, mid.dy); + } + innerPath.close(); + + final bounds = Rect.fromCircle(center: center, radius: baseRadius + 48.0); + + // Render soft glowing backdrop for the wavy perimeter + final glowPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 6.0 + ..color = const Color(0xFFA855F7).withValues(alpha: (0.35 * masterIntensity).clamp(0.0, 0.6)) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6.0); + canvas.drawPath(outerPath, glowPaint); + + // Render inner echo wave + final echoPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..color = const Color(0xFFC084FC).withValues(alpha: (0.35 * masterIntensity).clamp(0.0, 0.5)); + canvas.drawPath(innerPath, echoPaint); + + // Render crisp primary wavy outer line with sweeping gradient + final wavePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..strokeWidth = 2.4 + ..shader = const SweepGradient( + colors: [ + Color(0xFFA855F7), // Purple + Color(0xFFE879F9), // Fuchsia / Magenta + Color(0xFF818CF8), // Indigo / Violet + Color(0xFFA855F7), // Purple loop + ], + ).createShader(bounds); + + canvas.drawPath(outerPath, wavePaint); + } + + @override + bool shouldRepaint(covariant RadialFrequencyBarsPainter oldDelegate) { + return oldDelegate.animationProgress != animationProgress || + oldDelegate.soundLevel != soundLevel || + oldDelegate.isAiSpeaking != isAiSpeaking || + oldDelegate.isMuted != isMuted; + } +} diff --git a/apps/eva_mobile/lib/main.dart b/apps/eva_mobile/lib/main.dart index 6371be2..237ab12 100644 --- a/apps/eva_mobile/lib/main.dart +++ b/apps/eva_mobile/lib/main.dart @@ -25,7 +25,7 @@ class EvaApp extends StatelessWidget { title: 'EVA AI - Mental Health Companion', debugShowCheckedModeBanner: false, theme: AppTheme.lightTheme, - initialRoute: '/', + initialRoute: '/home', onGenerateRoute: AppRouter.onGenerateRoute, builder: (context, child) { return WebPhoneFrameWrapper( @@ -36,6 +36,3 @@ class EvaApp extends StatelessWidget { ); } } - -// Backwards compatibility alias -typedef LunaApp = EvaApp; diff --git a/apps/eva_mobile/lib/providers/daily_progress_provider.dart b/apps/eva_mobile/lib/providers/daily_progress_provider.dart index 1674e5b..8ba68b6 100644 --- a/apps/eva_mobile/lib/providers/daily_progress_provider.dart +++ b/apps/eva_mobile/lib/providers/daily_progress_provider.dart @@ -1,16 +1,25 @@ import 'dart:async'; -import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart' as http; -import '../config/app_config.dart'; +import '../features/chat/domain/entities/conversation_entity.dart'; +import '../features/chat/domain/repositories/chat_repository.dart'; +import '../features/chat/presentation/providers/chat_provider.dart'; +import '../features/diary/domain/repositories/diary_repository.dart'; +import '../features/diary/presentation/providers/diary_provider.dart'; import '../services/daily_progress_local_service.dart'; /// Notifier managing real-time reactive updates for the user's daily self-care progress. class DailyProgressNotifier extends StateNotifier<DailyProgressData> { + final ChatRepository _chatRepo; + final DiaryRepository _diaryRepo; StreamSubscription<DailyProgressData>? _streamSub; - DailyProgressNotifier() : super(DailyProgressData(date: DailyProgressLocalService.getTodayWibDate())) { + DailyProgressNotifier({ + required ChatRepository chatRepo, + required DiaryRepository diaryRepo, + }) : _chatRepo = chatRepo, + _diaryRepo = diaryRepo, + super(DailyProgressData(date: DailyProgressLocalService.getTodayWibDate())) { _init(); } @@ -82,43 +91,27 @@ class DailyProgressNotifier extends StateNotifier<DailyProgressData> { } } - /// Synchronize today's progress with remote backend + /// Synchronize today's progress with repositories Future<void> syncWithServer() async { - if (AppConfig.useMockData) return; - try { - final headers = await AppConfig.getAuthHeaders(); final results = await Future.wait([ // 0: Conversations today - http - .get(Uri.parse('${AppConfig.baseUrl}/conversations/today'), headers: headers) - .timeout(const Duration(seconds: 4)) - .catchError((_) => http.Response('{}', 500)), + _chatRepo.getConversations().catchError((_) => <ConversationEntity>[]), // 1: Diary today - http - .get(Uri.parse('${AppConfig.baseUrl}/diaries/today'), headers: headers) - .timeout(const Duration(seconds: 4)) - .catchError((_) => http.Response('{}', 500)), + _diaryRepo.getTodayDiary().catchError((_) => null), ]); bool hasConv = state.hasConversation; bool hasDiary = state.hasDiary; - final resConv = results[0]; - if (resConv.statusCode == 200) { - final data = jsonDecode(resConv.body); - if (data is Map<String, dynamic>) { - final count = data['total'] ?? (data['items'] as List?)?.length ?? 0; - if (count > 0) hasConv = true; - } + final convList = results[0] as List; + if (convList.isNotEmpty) { + hasConv = true; } - final resDiary = results[1]; - if (resDiary.statusCode == 200) { - final data = jsonDecode(resDiary.body); - if (data is Map<String, dynamic> && data['id'] != null) { - hasDiary = true; - } + final diary = results[1]; + if (diary != null) { + hasDiary = true; } if (hasConv != state.hasConversation || hasDiary != state.hasDiary) { @@ -154,5 +147,8 @@ class DailyProgressNotifier extends StateNotifier<DailyProgressData> { /// Global Riverpod provider for daily self-care progress. final dailyProgressProvider = StateNotifierProvider<DailyProgressNotifier, DailyProgressData>((ref) { - return DailyProgressNotifier(); + return DailyProgressNotifier( + chatRepo: ref.watch(chatRepositoryProvider), + diaryRepo: ref.watch(diaryRepositoryProvider), + ); }); diff --git a/apps/eva_mobile/lib/services/daily_progress_local_service.dart b/apps/eva_mobile/lib/services/daily_progress_local_service.dart index 11fddee..911ba9d 100644 --- a/apps/eva_mobile/lib/services/daily_progress_local_service.dart +++ b/apps/eva_mobile/lib/services/daily_progress_local_service.dart @@ -94,8 +94,7 @@ class DailyProgressLocalService { static Future<DailyProgressData> loadTodayProgress({DateTime? now}) async { final todayStr = getTodayWibDate(now); final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_getStorageKey(todayStr)) ?? - prefs.getString('luna_daily_progress_$todayStr'); + final raw = prefs.getString(_getStorageKey(todayStr)); if (raw == null || raw.isEmpty) { final initial = DailyProgressData(date: todayStr); diff --git a/apps/eva_mobile/lib/theme/app_colors.dart b/apps/eva_mobile/lib/theme/app_colors.dart index b81e6ba..05cc4d8 100644 --- a/apps/eva_mobile/lib/theme/app_colors.dart +++ b/apps/eva_mobile/lib/theme/app_colors.dart @@ -40,7 +40,9 @@ class AppColors { static const Color divider = Color(0x0E5358CB); // Soft gentle divider (5.5% periwinkle) - static const Color success = Color(0xFF4CAF50); + static const Color success = Color(0xFF4CAF50); // Soft Material Green + static const Color successContainer = Color(0xFFE8F5E9); // Soft Green Container + static const Color successDark = Color(0xFF2E7D32); // Dark Green for text/icons on light container static const Color warning = Color(0xFFFF9800); diff --git a/apps/eva_mobile/lib/theme/app_theme.dart b/apps/eva_mobile/lib/theme/app_theme.dart index 836b8f6..41f6415 100644 --- a/apps/eva_mobile/lib/theme/app_theme.dart +++ b/apps/eva_mobile/lib/theme/app_theme.dart @@ -16,17 +16,18 @@ class AppTheme { scaffoldBackgroundColor: AppColors.background, colorScheme: const ColorScheme.light( - primary: AppColors.primary, - + onPrimary: Colors.white, primaryContainer: AppColors.primaryContainer, - secondary: AppColors.secondary, - + onSecondary: Colors.white, tertiary: AppColors.tertiary, - surface: AppColors.surface, - + onSurface: AppColors.textPrimary, + ), + chipTheme: const ChipThemeData( + checkmarkColor: Colors.white, + selectedColor: AppColors.primary, ), appBarTheme: const AppBarTheme( diff --git a/apps/eva_mobile/lib/widgets/app_back_button.dart b/apps/eva_mobile/lib/widgets/app_back_button.dart new file mode 100644 index 0000000..b7c0ae8 --- /dev/null +++ b/apps/eva_mobile/lib/widgets/app_back_button.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; + +/// Standardized Circular Frosted Back Button for Eva Mobile. +/// +/// Follows the signature glassmorphic design established in SettingsScreen: +/// - Translucent circular badge (`BoxShape.circle`) +/// - Subtle border (`#E2E8F0` on light surfaces) +/// - Rounded back chevron (`Icons.arrow_back_rounded`) +/// - Automatic `Navigator.maybePop(context)` fallback when [onPressed] is omitted. +class AppBackButton extends StatelessWidget { + final VoidCallback? onPressed; + final bool isDark; + final IconData icon; + final double iconSize; + final double size; + final String? tooltip; + final Color? backgroundColor; + final Color? borderColor; + final Color? iconColor; + + const AppBackButton({ + super.key, + this.onPressed, + this.isDark = false, + this.icon = Icons.arrow_back_rounded, + this.iconSize = 20.0, + this.size = 40.0, + this.tooltip = 'Kembali', + this.backgroundColor, + this.borderColor, + this.iconColor, + }); + + /// Factory constructor optimized for dark backgrounds and gradients (e.g. VoiceCallScreen). + const AppBackButton.dark({ + super.key, + this.onPressed, + this.icon = Icons.arrow_back_rounded, + this.iconSize = 20.0, + this.size = 40.0, + this.tooltip = 'Kembali', + this.backgroundColor, + this.borderColor, + this.iconColor, + }) : isDark = true; + + @override + Widget build(BuildContext context) { + final effectiveBgColor = backgroundColor ?? + (isDark ? Colors.white.withValues(alpha: 0.15) : Colors.white.withValues(alpha: 0.8)); + final effectiveBorderColor = borderColor ?? + (isDark ? Colors.white.withValues(alpha: 0.25) : const Color(0xFFE2E8F0)); + final effectiveIconColor = iconColor ?? + (isDark ? Colors.white : AppColors.textPrimary); + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: effectiveBgColor, + shape: BoxShape.circle, + border: Border.all(color: effectiveBorderColor), + ), + child: IconButton( + icon: Icon(icon, size: iconSize, color: effectiveIconColor), + color: effectiveIconColor, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashRadius: size / 2, + tooltip: tooltip, + onPressed: onPressed ?? () => Navigator.maybePop(context), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/widgets/app_snack_bar.dart b/apps/eva_mobile/lib/widgets/app_snack_bar.dart new file mode 100644 index 0000000..11edafa --- /dev/null +++ b/apps/eva_mobile/lib/widgets/app_snack_bar.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../theme/app_colors.dart'; + +/// Semantic variants for application-wide snackbars. +enum SnackBarVariant { + primary, + success, + warning, + danger, +} + +/// Standardized, stylish floating SnackBar utility for Eva Mobile. +class AppSnackBar { + const AppSnackBar._(); + + /// Resolve background color based on variant. + static Color getBackgroundColor(SnackBarVariant variant) { + switch (variant) { + case SnackBarVariant.primary: + return AppColors.primary; + case SnackBarVariant.success: + return AppColors.success; + case SnackBarVariant.warning: + return const Color(0xFFF59E0B); // Amber + case SnackBarVariant.danger: + return const Color(0xFFDC2626); // Crimson + } + } + + /// Resolve default leading icon based on variant. + static IconData getDefaultIcon(SnackBarVariant variant) { + switch (variant) { + case SnackBarVariant.primary: + return Icons.auto_awesome_rounded; + case SnackBarVariant.success: + return Icons.check_circle_outline_rounded; + case SnackBarVariant.warning: + return Icons.warning_amber_rounded; + case SnackBarVariant.danger: + return Icons.error_outline_rounded; + } + } + + /// Builds a standardized floating [SnackBar] widget. + static SnackBar create({ + required String message, + SnackBarVariant variant = SnackBarVariant.primary, + IconData? icon, + Duration duration = const Duration(seconds: 3), + SnackBarAction? action, + Widget? leading, + }) { + return SnackBar( + content: Row( + children: [ + leading ?? + Icon( + icon ?? getDefaultIcon(variant), + color: Colors.white, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: GoogleFonts.inter( + fontSize: 13, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + backgroundColor: getBackgroundColor(variant), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: duration, + action: action, + ); + } + + /// Shows the SnackBar on the given [BuildContext]. + static void show( + BuildContext context, { + required String message, + SnackBarVariant variant = SnackBarVariant.primary, + IconData? icon, + Duration duration = const Duration(seconds: 3), + SnackBarAction? action, + Widget? leading, + }) { + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + create( + message: message, + variant: variant, + icon: icon, + duration: duration, + action: action, + leading: leading, + ), + ); + } + + /// Shows the SnackBar on a pre-captured [ScaffoldMessengerState] (useful across async gaps). + static void showWithMessenger( + ScaffoldMessengerState messenger, { + required String message, + SnackBarVariant variant = SnackBarVariant.primary, + IconData? icon, + Duration duration = const Duration(seconds: 3), + SnackBarAction? action, + Widget? leading, + }) { + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + create( + message: message, + variant: variant, + icon: icon, + duration: duration, + action: action, + leading: leading, + ), + ); + } + + /// Quick helper for Success notification (Emerald green). + static void showSuccess( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), + IconData? icon, + SnackBarAction? action, + }) { + show( + context, + message: message, + variant: SnackBarVariant.success, + duration: duration, + icon: icon, + action: action, + ); + } + + /// Quick helper for Warning notification (Amber). + static void showWarning( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), + IconData? icon, + SnackBarAction? action, + }) { + show( + context, + message: message, + variant: SnackBarVariant.warning, + duration: duration, + icon: icon, + action: action, + ); + } + + /// Quick helper for Error/Danger notification (Crimson red). + static void showError( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 4), + IconData? icon, + SnackBarAction? action, + }) { + show( + context, + message: message, + variant: SnackBarVariant.danger, + duration: duration, + icon: icon, + action: action, + ); + } + + /// Quick helper for Primary notification (Periwinkle). + static void showPrimary( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), + IconData? icon, + SnackBarAction? action, + Widget? leading, + }) { + show( + context, + message: message, + variant: SnackBarVariant.primary, + duration: duration, + icon: icon, + action: action, + leading: leading, + ); + } +} diff --git a/apps/eva_mobile/lib/widgets/custom_button.dart b/apps/eva_mobile/lib/widgets/custom_button.dart index b866886..fb80b04 100644 --- a/apps/eva_mobile/lib/widgets/custom_button.dart +++ b/apps/eva_mobile/lib/widgets/custom_button.dart @@ -1,10 +1,100 @@ import 'package:flutter/material.dart'; - import 'package:google_fonts/google_fonts.dart'; import '../theme/app_colors.dart'; +/// Primary custom pill button with optional icon, loading state, and outline style. +class CustomButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isOutline; + final bool isLoading; + final IconData? suffixIcon; + final IconData? prefixIcon; + final double height; + final double? width; + final Color? backgroundColor; + final Color? textColor; + const CustomButton({ + super.key, + required this.text, + this.onPressed, + this.isOutline = false, + this.isLoading = false, + this.suffixIcon, + this.prefixIcon, + this.height = 54.0, + this.width, + this.backgroundColor, + this.textColor, + }); + + @override + Widget build(BuildContext context) { + final effectiveBgColor = backgroundColor ?? (isOutline ? Colors.transparent : AppColors.primary); + final effectiveFgColor = textColor ?? (isOutline ? AppColors.primary : Colors.white); + + return SizedBox( + width: width ?? double.infinity, + height: height, + child: ElevatedButton( + onPressed: isLoading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: effectiveBgColor, + foregroundColor: effectiveFgColor, + disabledBackgroundColor: isOutline ? Colors.transparent : AppColors.primary.withValues(alpha: 0.6), + disabledForegroundColor: Colors.white.withValues(alpha: 0.8), + elevation: isOutline ? 0 : 4, + shadowColor: AppColors.primary.withValues(alpha: 0.3), + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + side: isOutline + ? BorderSide(color: backgroundColor ?? AppColors.primary, width: 1.5) + : BorderSide.none, + ), + ), + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + valueColor: AlwaysStoppedAnimation<Color>(Colors.white), + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (prefixIcon != null) ...[ + Icon(prefixIcon, size: 20, color: effectiveFgColor), + const SizedBox(width: 8), + ], + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: effectiveFgColor, + ), + ), + ), + if (suffixIcon != null) ...[ + const SizedBox(width: 8), + Icon(suffixIcon, size: 20, color: effectiveFgColor), + ], + ], + ), + ), + ); + } +} class CustomPillButton extends StatelessWidget { diff --git a/apps/eva_mobile/lib/widgets/eva_loading_orb.dart b/apps/eva_mobile/lib/widgets/eva_loading_orb.dart new file mode 100644 index 0000000..05181d3 --- /dev/null +++ b/apps/eva_mobile/lib/widgets/eva_loading_orb.dart @@ -0,0 +1,302 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../theme/app_colors.dart'; + +/// Color profile token for the fluid shimmering nodes inside the Eva Orb. +class _NodeColorProfile { + final Color core; + final Color highlight; + final Color ambient; + const _NodeColorProfile(this.core, this.highlight, this.ambient); +} + +/// A mesmerizing, fluid iridescent loading orb inspired by Eva AI's voice visualizer. +/// Displays an undulating iridescent sphere with organic swirling fluid lobes, +/// pulsing halo, and an optional calm breathing status message. +class EvaLoadingOrb extends StatefulWidget { + final double size; + final String? message; + final bool showHalo; + + const EvaLoadingOrb({ + super.key, + this.size = 180.0, + this.message, + this.showHalo = true, + }); + + @override + State<EvaLoadingOrb> createState() => _EvaLoadingOrbState(); +} + +class _EvaLoadingOrbState extends State<EvaLoadingOrb> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 3200), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final double orbSize = widget.size; + final double innerSize = orbSize * 0.78; + + return RepaintBoundary( + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final animProgress = _controller.value; + final double pulse = math.sin(animProgress * 2 * math.pi) * 0.5 + 0.5; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: orbSize, + height: orbSize, + child: Stack( + alignment: Alignment.center, + children: [ + // 1. Soft Ambient Iridescent Halo + if (widget.showHalo) + Container( + width: innerSize * 1.15, + height: innerSize * 1.15, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFFC7D2FE).withValues( + alpha: 0.25 + (pulse * 0.15), + ), + blurRadius: 36 + (pulse * 14), + spreadRadius: 2, + ), + BoxShadow( + color: const Color(0xFFFFD1DC).withValues( + alpha: 0.20 + (pulse * 0.10), + ), + blurRadius: 28, + spreadRadius: 1, + ), + ], + ), + ), + + // 2. Main Spherical Body with Soft Pastel Gradient + Container( + width: innerSize, + height: innerSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const RadialGradient( + center: Alignment(-0.25, -0.35), + radius: 0.95, + colors: [ + Color(0xFFFFFFFF), // Soft white highlight + Color(0xFFEDE9FE), // Pale lavender + Color(0xFFE0E7FF), // Soft periwinkle + Color(0xFFBAE6FD), // Soft cyan edge + ], + stops: [0.0, 0.45, 0.75, 1.0], + ), + border: Border.all( + color: const Color(0xFFE2E8F0).withValues(alpha: 0.7), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF818CF8).withValues(alpha: 0.25), + blurRadius: 16, + spreadRadius: 1, + ), + BoxShadow( + color: const Color(0xFFF472B6).withValues(alpha: 0.20), + blurRadius: 20, + spreadRadius: -2, + offset: const Offset(4, -2), + ), + ], + ), + child: ClipOval( + child: CustomPaint( + size: Size(innerSize, innerSize), + painter: _EvaOrbFluidPainter( + animProgress: animProgress, + ), + ), + ), + ), + ], + ), + ), + + // Optional Status Message + if (widget.message != null && widget.message!.isNotEmpty) ...[ + const SizedBox(height: 18), + AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: 0.75 + (pulse * 0.25), + child: Text( + widget.message!, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + letterSpacing: 0.2, + ), + ), + ), + ], + ], + ); + }, + ), + ); + } +} + +/// CustomPainter that renders organic swirling fluid lobes and silky ribbons. +class _EvaOrbFluidPainter extends CustomPainter { + final double animProgress; + + const _EvaOrbFluidPainter({required this.animProgress}); + + static const List<_NodeColorProfile> _profiles = [ + _NodeColorProfile(Color(0xFF0284C7), Color(0xFF38BDF8), Color(0xFFA7E6FF)), + _NodeColorProfile(Color(0xFF4F46E5), Color(0xFF6366F1), Color(0xFFC7D2FE)), + _NodeColorProfile(Color(0xFF7E22CE), Color(0xFFA855F7), Color(0xFFE9D5FF)), + _NodeColorProfile(Color(0xFFDB2777), Color(0xFFF472B6), Color(0xFFFCE7F3)), + _NodeColorProfile(Color(0xFFE11D48), Color(0xFFFB7185), Color(0xFFFFE4E6)), + ]; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final double scale = size.width / 174.0; + const int nodeCount = 5; + + // 1. Fluid Abstract Background Atmosphere + final backdropPaint = Paint() + ..color = const Color(0xFFEDE9FE).withValues(alpha: 0.40) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 16.0 * scale); + canvas.drawCircle(center, 44.0 * scale, backdropPaint); + + // 2. Swirling Organic Fluid Lobes + for (int i = 0; i < nodeCount; i++) { + final profile = _profiles[i]; + final double theta = (i * 2 * math.pi / nodeCount) + (animProgress * 2 * math.pi * 1.0); + + final double lissajousX = (math.cos(theta) * (18.0 + math.sin(animProgress * 2 * math.pi * 1.0 + i) * 4.0)) * scale; + final double lissajousY = (math.sin(theta) * (16.0 + math.cos(animProgress * 2 * math.pi * 1.0 - i) * 4.0)) * scale; + final Offset lobeCenter = center + Offset(lissajousX, lissajousY); + + final double baseRadius = 32.0 * scale; + final double breathing = math.sin((animProgress * 2 * math.pi * 2.0) + (i * 1.3)) * 4.5 * scale; + final double lobeRadius = baseRadius + breathing; + + final Path lobePath = Path(); + const int vertices = 16; + for (int v = 0; v <= vertices; v++) { + final double angle = (v / vertices) * 2 * math.pi; + final double wave1 = math.sin((angle * 2.0) + (animProgress * 2 * math.pi * 2.0) + i) * 0.16; + final double wave2 = math.cos((angle * 3.0) - (animProgress * 2 * math.pi * 1.0) + (i * 0.8)) * 0.10; + final double r = lobeRadius * (1.0 + wave1 + wave2); + final double vx = lobeCenter.dx + r * math.cos(angle); + final double vy = lobeCenter.dy + r * math.sin(angle); + if (v == 0) { + lobePath.moveTo(vx, vy); + } else { + lobePath.lineTo(vx, vy); + } + } + lobePath.close(); + + final lobePaint = Paint() + ..shader = RadialGradient( + center: Alignment( + -0.20 * math.cos(theta), + -0.20 * math.sin(theta), + ), + radius: 0.90, + colors: [ + profile.highlight.withValues(alpha: 0.82), + profile.core.withValues(alpha: 0.62), + profile.ambient.withValues(alpha: 0.22), + profile.core.withValues(alpha: 0.0), + ], + stops: const [0.0, 0.42, 0.75, 1.0], + ).createShader(Rect.fromCircle(center: lobeCenter, radius: lobeRadius * 1.2)) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 10.0 * scale); + + canvas.drawPath(lobePath, lobePaint); + } + + // 3. Curving Fluid Silk Ribbon + final double ribbonAngle = animProgress * 2 * math.pi * 1.0; + final Path ribbon = Path(); + ribbon.moveTo( + center.dx + 38 * scale * math.cos(ribbonAngle), + center.dy + 38 * scale * math.sin(ribbonAngle), + ); + ribbon.quadraticBezierTo( + center.dx + 14 * scale * math.cos(ribbonAngle + 1.8), + center.dy + 14 * scale * math.sin(ribbonAngle + 1.8), + center.dx + 38 * scale * math.cos(ribbonAngle + math.pi), + center.dy + 38 * scale * math.sin(ribbonAngle + math.pi), + ); + + final ribbonPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 7.5 * scale + ..strokeCap = StrokeCap.round + ..shader = SweepGradient( + center: Alignment.center, + startAngle: ribbonAngle, + endAngle: ribbonAngle + (2 * math.pi), + colors: [ + _profiles[0].highlight.withValues(alpha: 0.45), + _profiles[2].highlight.withValues(alpha: 0.45), + _profiles[3].highlight.withValues(alpha: 0.40), + _profiles[0].highlight.withValues(alpha: 0.45), + ], + ).createShader(Rect.fromCircle(center: center, radius: 42.0 * scale)) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 5.0 * scale); + + canvas.drawPath(ribbon, ribbonPaint); + + // 4. Specular Glass Glare Overlay + final glarePaint = Paint() + ..shader = RadialGradient( + center: const Alignment(-0.35, -0.45), + radius: 0.70, + colors: [ + Colors.white.withValues(alpha: 0.35), + Colors.white.withValues(alpha: 0.08), + Colors.white.withValues(alpha: 0.0), + ], + stops: const [0.0, 0.45, 1.0], + ).createShader(Rect.fromCircle(center: center, radius: 52.0 * scale)); + canvas.drawCircle(center, 50.0 * scale, glarePaint); + } + + @override + bool shouldRepaint(covariant _EvaOrbFluidPainter oldDelegate) { + return oldDelegate.animProgress != animProgress; + } +} + diff --git a/apps/eva_mobile/lib/widgets/floating_nav_bar.dart b/apps/eva_mobile/lib/widgets/floating_nav_bar.dart index ab5c1dd..a6385dc 100644 --- a/apps/eva_mobile/lib/widgets/floating_nav_bar.dart +++ b/apps/eva_mobile/lib/widgets/floating_nav_bar.dart @@ -5,11 +5,21 @@ import '../theme/app_colors.dart'; class FloatingNavBar extends StatelessWidget { final int currentIndex; final Function(int) onTap; + final VoidCallback? onCenterTap; + final Key? centerButtonKey; + final Key? journalTabKey; + final Key? trenTabKey; + final Key? careTabKey; const FloatingNavBar({ super.key, required this.currentIndex, required this.onTap, + this.onCenterTap, + this.centerButtonKey, + this.journalTabKey, + this.trenTabKey, + this.careTabKey, }); /// Map page currentIndex (0..3) to 5-slot layout column index: @@ -116,6 +126,7 @@ class FloatingNavBar extends StatelessWidget { // Slot 1: Jurnal (Diary) Expanded( + key: journalTabKey, flex: 1, child: _buildSideNavItem( index: 1, @@ -133,6 +144,7 @@ class FloatingNavBar extends StatelessWidget { // Slot 3: Tren (Monitoring) Expanded( + key: trenTabKey, flex: 1, child: _buildSideNavItem( index: 2, @@ -142,14 +154,15 @@ class FloatingNavBar extends StatelessWidget { ), ), - // Slot 4: Profil (Profile) + // Slot 4: Layanan (Care & Komunitas) Expanded( + key: careTabKey, flex: 1, child: _buildSideNavItem( index: 3, - iconUnselected: Icons.person_outline, - iconSelected: Icons.person, - label: 'Profil', + iconUnselected: Icons.volunteer_activism_outlined, + iconSelected: Icons.volunteer_activism, + label: 'Layanan', ), ), ], @@ -166,7 +179,8 @@ class FloatingNavBar extends StatelessWidget { Positioned( bottom: 30, child: GestureDetector( - onTap: () { + key: centerButtonKey, + onTap: onCenterTap ?? () { Navigator.pushNamed(context, '/chat'); }, child: Container( diff --git a/apps/eva_mobile/lib/widgets/web_phone_frame_wrapper.dart b/apps/eva_mobile/lib/widgets/web_phone_frame_wrapper.dart index 2cb05f7..8476489 100644 --- a/apps/eva_mobile/lib/widgets/web_phone_frame_wrapper.dart +++ b/apps/eva_mobile/lib/widgets/web_phone_frame_wrapper.dart @@ -92,7 +92,7 @@ class WebPhoneFrameWrapper extends StatelessWidget { ), const SizedBox(width: 8), Text( - 'EVA AI • Mobile Web Simulator', + 'Eva AI • Mobile Web Simulator', style: GoogleFonts.inter( color: Colors.white70, fontSize: 12, diff --git a/apps/eva_mobile/pubspec.yaml b/apps/eva_mobile/pubspec.yaml index 41a1b7b..6f576bc 100644 --- a/apps/eva_mobile/pubspec.yaml +++ b/apps/eva_mobile/pubspec.yaml @@ -57,7 +57,6 @@ flutter: assets: - assets/images/ - assets/images/eva_logo.png - - assets/images/luna_logo.png - assets/animations/ - assets/audio/ diff --git a/apps/eva_mobile/test/core/app_router_test.dart b/apps/eva_mobile/test/core/app_router_test.dart index eadb21d..6648bc4 100644 --- a/apps/eva_mobile/test/core/app_router_test.dart +++ b/apps/eva_mobile/test/core/app_router_test.dart @@ -53,6 +53,21 @@ void main() { expect(route!.settings.name, '/diary_detail'); }); + test('Returns PageRouteBuilder for /coin_purchase and /coins', () { + final route1 = AppRouter.onGenerateRoute( + const RouteSettings(name: '/coin_purchase'), + ); + expect(route1, isNotNull); + expect(route1, isA<PageRouteBuilder>()); + expect(route1!.settings.name, '/coin_purchase'); + + final route2 = AppRouter.onGenerateRoute( + const RouteSettings(name: '/coins'), + ); + expect(route2, isNotNull); + expect(route2, isA<PageRouteBuilder>()); + }); + test('Returns null for unknown route to let MaterialApp handle it or 404', () { final route = AppRouter.onGenerateRoute( const RouteSettings(name: '/unknown_non_existent_route'), diff --git a/apps/eva_mobile/test/core/network/fallback_policy_test.dart b/apps/eva_mobile/test/core/network/fallback_policy_test.dart new file mode 100644 index 0000000..8bcd273 --- /dev/null +++ b/apps/eva_mobile/test/core/network/fallback_policy_test.dart @@ -0,0 +1,59 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/core/network/api_client.dart'; +import 'package:eva_mobile/core/network/fallback_policy.dart'; + +void main() { + group('FallbackPolicy', () { + test('should return true for connection/socket error', () { + expect(FallbackPolicy.shouldFallback(const SocketException('Failed host lookup')), isTrue); + expect(FallbackPolicy.shouldFallback(TimeoutException('Request timed out')), isTrue); + expect(FallbackPolicy.shouldFallback(Exception('Connection refused')), isTrue); + }); + + test('should return true for HTTP 404 Not Found and HTTP 501 Not Implemented', () { + expect(FallbackPolicy.shouldFallback(ApiException('Not Found', statusCode: 404)), isTrue); + expect(FallbackPolicy.shouldFallback(ApiException('Not Implemented', statusCode: 501)), isTrue); + expect(FallbackPolicy.shouldFallback(ApiException('Connection dropped', statusCode: null)), isTrue); + }); + + test('should return false for business errors 401, 403, 422, and 500', () { + expect(FallbackPolicy.shouldFallback(ApiException('Unauthorized', statusCode: 401)), isFalse); + expect(FallbackPolicy.shouldFallback(ApiException('Forbidden', statusCode: 403)), isFalse); + expect(FallbackPolicy.shouldFallback(ApiException('Unprocessable Entity', statusCode: 422)), isFalse); + expect(FallbackPolicy.shouldFallback(ApiException('Internal Server Error', statusCode: 500)), isFalse); + }); + }); + + group('FallbackExecutor', () { + test('should execute remote successfully without fallback when remote succeeds', () async { + final result = await FallbackExecutor.execute<String>( + remoteCall: () async => 'remote_ok', + mockCall: () async => 'mock_data', + operationName: 'testOperation', + ); + expect(result, equals('remote_ok')); + }); + + test('should fallback to mock when remote fails with 404', () async { + final result = await FallbackExecutor.execute<String>( + remoteCall: () async => throw ApiException('Not found', statusCode: 404), + mockCall: () async => 'mock_data', + operationName: 'testOperation404', + ); + expect(result, equals('mock_data')); + }); + + test('should rethrow original exception when remote fails with 500', () async { + expect( + () async => FallbackExecutor.execute<String>( + remoteCall: () async => throw ApiException('Backend bug', statusCode: 500), + mockCall: () async => 'mock_data', + operationName: 'testOperation500', + ), + throwsA(isA<ApiException>().having((e) => e.statusCode, 'statusCode', 500)), + ); + }); + }); +} diff --git a/apps/eva_mobile/test/features/care/care_provider_test.dart b/apps/eva_mobile/test/features/care/care_provider_test.dart new file mode 100644 index 0000000..12d0c33 --- /dev/null +++ b/apps/eva_mobile/test/features/care/care_provider_test.dart @@ -0,0 +1,419 @@ +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/core/network/api_client.dart'; +import 'package:eva_mobile/features/care/data/models/psychologist_model.dart'; +import 'package:eva_mobile/features/care/data/models/clinic_model.dart'; +import 'package:eva_mobile/features/care/data/models/community_status_model.dart'; +import 'package:eva_mobile/features/care/data/datasources/mock_care_datasource.dart'; +import 'package:eva_mobile/features/care/data/repositories/care_repository_impl.dart'; +import 'package:eva_mobile/features/care/data/repositories/fallback_care_repository.dart'; +import 'package:eva_mobile/features/care/domain/entities/clinic_entity.dart'; +import 'package:eva_mobile/features/care/domain/entities/community_status_entity.dart'; +import 'package:eva_mobile/features/care/domain/entities/consultation_session_entity.dart'; +import 'package:eva_mobile/features/care/domain/entities/psychologist_entity.dart'; +import 'package:eva_mobile/features/care/domain/repositories/care_repository.dart'; +import 'package:eva_mobile/features/care/presentation/providers/care_provider.dart'; + +void main() { + group('Care Domain & Model Unit Tests', () { + test('PsychologistModel correctly serializes and deserializes', () { + final json = { + 'id': 'psy_test', + 'name': 'Test Psychologist, M.Psi.', + 'title': 'Psikolog Klinis', + 'registration_number': 'SIPP: 1234', + 'avatar_url': 'https://example.com/avatar.png', + 'rating': 4.9, + 'review_count': 25, + 'experience_years': 4, + 'specialties': ['Kecemasan', 'Stres'], + 'coins_per_session': 50, + 'duration_minutes': 50, + 'available_online': true, + 'clinic_affiliation': 'Klinik Sehat', + 'bio': 'Test bio description', + 'review_tags': [ + {'label': 'Sangat Sabar & Empati', 'sentiment': 'positive'}, + {'label': 'Slot Sesi Cepat Penuh', 'sentiment': 'negative'}, + ], + 'testimonials': [ + { + 'id': 'rev_test_1', + 'patient_name': 'Pasien Terverifikasi (A.B.)', + 'sentiment': 'positive', + 'label': 'Sangat Sabar & Empati', + 'description': 'Konselingnya sangat membantu meredakan overthinking.', + 'rating': 5.0, + 'time_ago': '2 hari lalu', + }, + ], + 'patient_stats': { + 'total_patients_treated': 320, + 'total_sessions_completed': 580, + 'satisfaction_rate': 98.7, + 'positive_review_rate': 96.0, + 'recovery_rate': 94.0, + 'avg_recovery_sessions': 3.7, + 'recovery_description': '94% pasien merasakan perbaikan kecemasan signifikan', + }, + }; + + final model = PsychologistModel.fromJson(json); + expect(model.id, 'psy_test'); + expect(model.name, 'Test Psychologist, M.Psi.'); + expect(model.isPsychiatrist, isFalse); + expect(model.specialties.length, 2); + expect(model.coinsPerSession, 50); + expect(model.feePerSession, 50); + expect(model.effectiveReviewTags.length, 2); + expect(model.effectiveReviewTags[0].isPositive, isTrue); + expect(model.effectiveReviewTags[1].isNegative, isTrue); + expect(model.effectiveTestimonials.length, 1); + expect(model.effectiveTestimonials[0].label, 'Sangat Sabar & Empati'); + expect(model.patientStats.totalPatientsTreated, 320); + expect(model.patientStats.positiveReviewRate, 96.0); + expect(model.patientStats.negativeReviewRate, 4.0); + + final outJson = model.toJson(); + expect(outJson['registration_number'], 'SIPP: 1234'); + expect(outJson['coins_per_session'], 50); + expect(outJson['fee_per_session'], 50); + expect(outJson['patient_stats']['total_patients_treated'], 320); + }); + + test('Psychiatrist detection via title', () { + const psikiater = PsychologistModel( + id: 'dr_1', + name: 'dr. Budi, Sp.KJ', + title: 'Dokter Spesialis Kedokteran Jiwa', + registrationNumber: 'STR: 9876', + avatarUrl: '', + rating: 5.0, + reviewCount: 10, + experienceYears: 8, + specialties: ['Bipolar'], + coinsPerSession: 60, + bio: 'Bio psikiater', + ); + + expect(psikiater.isPsychiatrist, isTrue); + expect(psikiater.coinsPerSession, 60); + }); + + test('ClinicModel correctly deserializes from JSON', () { + final json = { + 'id': 'clinic_test', + 'name': 'Klinik Mitra Sehat', + 'address': 'Jl. Sudirman No. 1', + 'city': 'Jakarta Selatan', + 'distance_km': 1.8, + 'photo_url': 'https://example.com/clinic.png', + 'phone': '021-123456', + 'facilities': ['Ruang Konseling', 'Apotek'], + 'rating': 4.85, + }; + + final clinic = ClinicModel.fromJson(json); + expect(clinic.name, 'Klinik Mitra Sehat'); + expect(clinic.distanceKm, 1.8); + expect(clinic.facilities.contains('Apotek'), isTrue); + }); + + test('CommunityStatusModel correctly handles state mutations', () { + final status = const CommunityStatusModel( + isMember: false, + isVipActive: false, + vipRemainingDays: 0, + ); + + final upgraded = status.copyWith( + isMember: true, + isVipActive: true, + vipRemainingDays: 30, + ); + + expect(upgraded.isMember, isTrue); + expect(upgraded.isVipActive, isTrue); + expect(upgraded.vipRemainingDays, 30); + }); + + test('Psychologist and Volunteer badges combination logic', () { + // 1. Volunteer without certificate (free) + const volunteer = PsychologistModel( + id: 'vol_1', + name: 'Bayu Aditya', + title: 'Relawan Sebaya', + registrationNumber: 'Cert PFA', + avatarUrl: '', + rating: 4.8, + reviewCount: 10, + experienceYears: 1, + specialties: ['Curhat'], + coinsPerSession: 0, + bio: 'Bio', + practitionerType: PractitionerType.volunteer, + hasPsychologistCertificate: false, + isVolunteerRole: true, + ); + expect(volunteer.isVolunteer, isTrue); + expect(volunteer.hasPsychologistCertificate, isFalse); + expect(volunteer.hasBothBadges, isFalse); + expect(volunteer.badgeHeaderTitle, 'Relawan'); + + // 2. Psychologist with certificate only + const pro = PsychologistModel( + id: 'pro_1', + name: 'Dimas Wicaksono', + title: 'Psikolog Klinis', + registrationNumber: 'SIPP: 1234', + avatarUrl: '', + rating: 4.9, + reviewCount: 20, + experienceYears: 5, + specialties: ['Karir'], + coinsPerSession: 60, + bio: 'Bio', + practitionerType: PractitionerType.professional, + hasPsychologistCertificate: true, + isVolunteerRole: false, + ); + expect(pro.isVolunteer, isFalse); + expect(pro.hasPsychologistCertificate, isTrue); + expect(pro.hasBothBadges, isFalse); + expect(pro.badgeHeaderTitle, 'Profesional'); + + // 3. User with BOTH badges: Psychologist AND Volunteer + const both = PsychologistModel( + id: 'both_1', + name: 'Amanda Zahra', + title: 'Psikolog & Relawan', + registrationNumber: 'SIPP: 5678', + avatarUrl: '', + rating: 5.0, + reviewCount: 50, + experienceYears: 6, + specialties: ['Kecemasan'], + coinsPerSession: 50, + bio: 'Bio', + practitionerType: PractitionerType.professional, + hasPsychologistCertificate: true, + isVolunteerRole: true, + ); + expect(both.isVolunteer, isTrue); + expect(both.hasPsychologistCertificate, isTrue); + expect(both.hasBothBadges, isTrue); + expect(both.badgeHeaderTitle, 'Relawan & Profesional'); + }); + }); + + group('MockCareDataSource Functional Tests', () { + late MockCareDataSource dataSource; + + setUp(() { + dataSource = MockCareDataSource(); + }); + + test('Fetches default psychologists list', () async { + final list = await dataSource.getPsychologists(); + expect(list.isNotEmpty, isTrue); + expect(list.length, greaterThanOrEqualTo(3)); + }); + + test('Filters psychologists by practitioner category', () async { + final professionals = await dataSource.getPsychologists(category: 'professional'); + expect(professionals.isNotEmpty, isTrue); + for (final p in professionals) { + expect(p.isProfessional, isTrue); + } + + final volunteers = await dataSource.getPsychologists(category: 'volunteer'); + expect(volunteers.isNotEmpty, isTrue); + for (final p in volunteers) { + expect(p.isVolunteer, isTrue); + } + }); + + test('Filters psychologists by search query', () async { + final results = await dataSource.getPsychologists(query: 'Amanda'); + expect(results.length, 1); + expect(results.first.name, contains('Amanda')); + }); + + test('Fetches clinics and filters by query', () async { + final clinics = await dataSource.getClinics(); + expect(clinics.isNotEmpty, isTrue); + + final bandungClinics = await dataSource.getClinics(query: 'Bandung'); + expect(bandungClinics.length, 1); + expect(bandungClinics.first.city, 'Bandung'); + }); + + test('Simulates joining Discord community with 1-month VIP trial', () async { + final initial = await dataSource.getCommunityStatus(); + expect(initial.isMember, isFalse); + expect(initial.isVipActive, isFalse); + + final joined = await dataSource.joinCommunityWithVipTrial(); + expect(joined.isMember, isTrue); + expect(joined.isVipActive, isTrue); + expect(joined.vipRemainingDays, 30); + }); + + test('Books a new consultation session and updates history', () async { + final initialSessions = await dataSource.getConsultationSessions(); + final initialCount = initialSessions.length; + + final newSession = await dataSource.bookConsultation( + psychologistId: 'psy_1', + preferredDateTime: DateTime.now().add(const Duration(days: 2)), + sessionType: ConsultationType.onlineVideo, + complaints: 'Merasa overthinking saat malam hari', + ); + + expect(newSession.id, startsWith('sess_')); + expect(newSession.psychologist.id, 'psy_1'); + expect(newSession.status, ConsultationStatus.upcoming); + expect(newSession.type, ConsultationType.onlineVideo); + expect(newSession.meetingUrl, isNotNull); + + final updatedSessions = await dataSource.getConsultationSessions(); + expect(updatedSessions.length, initialCount + 1); + expect(updatedSessions.first.id, newSession.id); + }); + }); + + group('FallbackCareRepository & Mode API vs Fallback Dummy Tests', () { + late FakeRemoteCareRepository fakeRemote; + late MockCareDataSource mockDataSource; + late CareRepository mockRepo; + late FallbackCareRepository fallbackRepo; + + setUp(() { + fakeRemote = FakeRemoteCareRepository(); + mockDataSource = MockCareDataSource(); + mockRepo = CareRepositoryImpl(mockDataSource); + fallbackRepo = FallbackCareRepository(remote: fakeRemote, mock: mockRepo); + }); + + test('Mode API: Returns remote data when remote API endpoint succeeds', () async { + fakeRemote.shouldThrow404 = false; + final results = await fallbackRepo.getPsychologists(); + expect(results.length, 1); + expect(results.first.id, 'remote_psy_1'); + expect(results.first.name, 'Remote Psychologist, M.Psi.'); + expect(results.first.coinsPerSession, 45); + }); + + test('Fallback Dummy: Transparently falls back to mock data on 404 (Endpoint not yet implemented)', () async { + fakeRemote.shouldThrow404 = true; + final results = await fallbackRepo.getPsychologists(); + expect(results.isNotEmpty, isTrue); + expect(results.any((p) => p.name.contains('Amanda')), isTrue); + }); + + test('Fallback Dummy: Transparently falls back to mock data on network error / connection refused (Offline)', () async { + fakeRemote.shouldThrowSocket = true; + final results = await fallbackRepo.getPsychologists(); + expect(results.isNotEmpty, isTrue); + expect(results.any((p) => p.name.contains('Amanda')), isTrue); + }); + + test('Mode API: Propagates non-fallback server errors (HTTP 500) without masking backend bugs', () async { + fakeRemote.shouldThrow500 = true; + expect( + () async => fallbackRepo.getPsychologists(), + throwsA(isA<ApiException>().having((e) => e.statusCode, 'statusCode', 500)), + ); + }); + + test('Fallback Dummy: Falls back to mock data for clinics on 404', () async { + fakeRemote.shouldThrow404 = true; + final clinics = await fallbackRepo.getClinics(); + expect(clinics.isNotEmpty, isTrue); + }); + + test('Fallback Dummy: Falls back to mock data for community status on 404', () async { + fakeRemote.shouldThrow404 = true; + final status = await fallbackRepo.getCommunityStatus(); + expect(status.isMember, isFalse); + }); + }); + + group('careRepositoryProvider DI Switching Tests', () { + test('Resolves to CareRepositoryImpl when useMockData is true', () { + AppConfig.useMockData = true; + final container = ProviderContainer(); + addTearDown(container.dispose); + + final repo = container.read(careRepositoryProvider); + expect(repo, isA<CareRepositoryImpl>()); + }); + }); +} + +class FakeRemoteCareRepository implements CareRepository { + bool shouldThrow404 = false; + bool shouldThrow500 = false; + bool shouldThrowSocket = false; + + final List<PsychologistEntity> remotePsychologists = [ + const PsychologistModel( + id: 'remote_psy_1', + name: 'Remote Psychologist, M.Psi.', + title: 'Psikolog Klinis Remote', + registrationNumber: 'SIPP: 9999', + avatarUrl: '', + rating: 5.0, + reviewCount: 1, + experienceYears: 1, + specialties: ['Remote Test'], + bio: 'Remote bio test', + coinsPerSession: 45, + ), + ]; + + @override + Future<List<PsychologistEntity>> getPsychologists({String? category, String? query}) async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + if (shouldThrow500) throw ApiException('Internal Error', statusCode: 500); + if (shouldThrowSocket) throw const SocketException('Connection refused'); + return remotePsychologists; + } + + @override + Future<List<ClinicEntity>> getClinics({String? query}) async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + return []; + } + + @override + Future<CommunityStatusEntity> getCommunityStatus() async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + return const CommunityStatusModel(isMember: true, isVipActive: false); + } + + @override + Future<CommunityStatusEntity> joinCommunityWithVipTrial() async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + return const CommunityStatusModel(isMember: true, isVipActive: true, vipRemainingDays: 30); + } + + @override + Future<List<ConsultationSessionEntity>> getConsultationSessions() async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + return []; + } + + @override + Future<ConsultationSessionEntity> bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + throw UnimplementedError(); + } +} diff --git a/apps/eva_mobile/test/features/care/care_screen_test.dart b/apps/eva_mobile/test/features/care/care_screen_test.dart new file mode 100644 index 0000000..caf0147 --- /dev/null +++ b/apps/eva_mobile/test/features/care/care_screen_test.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/core/routes/app_router.dart'; +import 'package:eva_mobile/features/care/presentation/screens/care_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_schedule_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_history_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/psychologist_video_call_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/psychologist_detail_screen.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/care_quick_actions.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/community_banner_card.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/psychologist_card.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/psychologist_certificate_carousel.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/clinic_location_map_card.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/consultation_result_bottom_sheet.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/psychologist_role_badge.dart'; + +void main() { + setUp(() { + AppConfig.useMockData = true; + SharedPreferences.setMockInitialValues({ + 'user_name': 'Alexander Agung', + 'user_email': 'alex@example.com', + 'user_coin_balance': 120, + }); + }); + + Widget createWidgetUnderTest() { + return const ProviderScope( + child: MaterialApp( + onGenerateRoute: AppRouter.onGenerateRoute, + home: CareScreen(), + ), + ); + } + + testWidgets('CareScreen renders header, 4 quick action buttons, and filters', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Verify Title & Subtitle + expect(find.text('Layanan Konseling'), findsOneWidget); + expect(find.text('Konseling psikolog berlisensi & relawan sebaya'), findsOneWidget); + + // Verify Community Banner is REMOVED + expect(find.byType(CommunityBannerCard), findsNothing); + expect(find.text('KOMUNITAS SAFE SPACE'), findsNothing); + + // Verify 4 Quick Action Buttons + expect(find.byType(CareQuickActions), findsOneWidget); + expect(find.text('Jadwal'), findsOneWidget); + expect(find.text('Riwayat'), findsOneWidget); + expect(find.text('Koin Saya'), findsOneWidget); + expect(find.text('Panduan'), findsOneWidget); + + // Verify Filter Chips: Semua, Psikolog, Relawan + expect(find.text('Semua'), findsOneWidget); + expect(find.text('Psikolog'), findsOneWidget); + expect(find.text('Relawan'), findsWidgets); + expect(find.text('Psikolog Profesional'), findsNothing); + expect(find.text('Psikolog Freelance'), findsNothing); + expect(find.text('Volunteer'), findsNothing); + + // Verify Psychologist Cards rendered from mock data + expect(find.byType(PsychologistCard), findsWidgets); + expect(find.text('Amanda Zahra, M.Psi., Psikolog'), findsOneWidget); + + // Verify Tag chips rendered on cards (without volunteer explanation container) + expect(find.text('Profesional'), findsWidgets); + expect(find.text('Relawan'), findsWidgets); + }); + + testWidgets('Tapping Psikolog and Relawan filter chips filters counselors list accordingly', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Tap on Psikolog chip + final psikologChip = find.widgetWithText(ChoiceChip, 'Psikolog'); + expect(psikologChip, findsOneWidget); + await tester.tap(psikologChip); + await tester.pumpAndSettle(); + + // Verify psychologists are shown + expect(find.text('Amanda Zahra, M.Psi., Psikolog'), findsOneWidget); + expect(find.text('Dimas Wicaksono, M.Psi., Psikolog'), findsOneWidget); + + // Tap on Relawan chip + final relawanChip = find.widgetWithText(ChoiceChip, 'Relawan'); + expect(relawanChip, findsOneWidget); + await tester.tap(relawanChip); + await tester.pumpAndSettle(); + + // Verify volunteer counselors are shown + expect(find.text('Bayu Aditya, S.Psi.'), findsOneWidget); + }); + + testWidgets('Tapping psychologist card navigates to PsychologistDetailScreen with patient stats, 4 tags, and 5 testimonials', (tester) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Tap on Amanda Zahra's card + final card = find.text('Amanda Zahra, M.Psi., Psikolog'); + expect(card, findsOneWidget); + await tester.tap(card); + await tester.pumpAndSettle(); + + // Verify PsychologistDetailScreen is displayed + expect(find.byType(PsychologistDetailScreen), findsOneWidget); + expect(find.text('Profil Konselor'), findsOneWidget); + + // 1. Verify Patient Statistics section + expect(find.text('Statistik Pasien Dirawat'), findsOneWidget); + expect(find.text('284+'), findsOneWidget); + expect(find.text('Pasien Dirawat'), findsOneWidget); + expect(find.text('98.6%'), findsOneWidget); + expect(find.text('Tingkat Puas'), findsOneWidget); + expect(find.text('3.8 Sesi'), findsOneWidget); + expect(find.text('96% Ulasan Positif'), findsOneWidget); + expect(find.text('4% Catatan / Masukan'), findsOneWidget); + expect(find.text('94.5% pasien merasakan penurunan kecemasan & stres kerja signifikan'), findsOneWidget); + + // 2. Verify Ulasan & Kesan Pasien is REMOVED (covered by Testimoni and Statistik) + expect(find.text('Ulasan & Kesan Pasien'), findsNothing); + + // 3. Verify Focus Issues & Payment Card in body are REMOVED + expect(find.text('FOKUS ISU & SPESIALISASI'), findsNothing); + expect(find.text('Biaya Sesi: 50 Koin EVA'), findsNothing); + + // 4. Verify 5 Patient Testimonials rendered with short review badges & descriptions + expect(find.text('TESTIMONI PASIEN'), findsOneWidget); + expect(find.text('5 ulasan pengalaman konseling pasien terverifikasi:'), findsOneWidget); + expect(find.text('Pasien Terverifikasi (A. Z.)'), findsOneWidget); + expect(find.text('Pasien Terverifikasi (R. K.)'), findsOneWidget); + expect(find.text('Pasien Terverifikasi (M. F.)'), findsOneWidget); + expect(find.text('Pasien Terverifikasi (N. D.)'), findsOneWidget); + expect(find.text('Pasien Terverifikasi (H. P.)'), findsOneWidget); + + // 5. Verify Clean Certificate Carousel in Detail Screen (Photo & Label) + expect(find.byType(PsychologistCertificateCarousel), findsOneWidget); + expect(find.text('Sertifikat & Lisensi'), findsOneWidget); + expect(find.text('1 / 4'), findsOneWidget); + expect(find.text('SIPP: 1988-12-04-2021 (HIMPSI)'), findsOneWidget); + expect(find.text('Perbesar'), findsWidgets); + + // 6. Verify Bottom Bar consultation fee & action button + expect(find.text('Biaya Konsultasi'), findsOneWidget); + expect(find.text('50 Koin'), findsOneWidget); + expect(find.text('Jadwalkan Konsultasi'), findsOneWidget); + + // 7. Verify "Lihat Semua" opens modal bottom sheet with filter tabs + final lihatSemuaBtn = find.text('Lihat Semua'); + expect(lihatSemuaBtn, findsOneWidget); + await tester.tap(lihatSemuaBtn); + await tester.pumpAndSettle(); + + expect(find.text('Semua Testimoni Pasien'), findsOneWidget); + expect(find.text('Semua (5)'), findsOneWidget); + expect(find.text('Positif (4)'), findsOneWidget); + expect(find.text('Catatan (1)'), findsOneWidget); + }); + + testWidgets('Tapping Jadwal navigates to ConsultationScheduleScreen and launches PsychologistVideoCallScreen', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + final jadwalBtn = find.text('Jadwal'); + expect(jadwalBtn, findsOneWidget); + await tester.tap(jadwalBtn); + await tester.pumpAndSettle(); + + // Verify ConsultationScheduleScreen is displayed + expect(find.byType(ConsultationScheduleScreen), findsOneWidget); + expect(find.text('Jadwal Sesi Mendatang'), findsOneWidget); + expect(find.text('Waktunya Sesi Konsultasi!'), findsOneWidget); + + // 1. Verify that upcoming session outside session time is locked with "Belum Waktunya" + final lockedBtn = find.text('Belum Waktunya').first; + expect(lockedBtn, findsOneWidget); + await tester.tap(lockedBtn); + await tester.pumpAndSettle(); + + // Verify "Belum Waktunya Sesi" dialog appears preventing entry + expect(find.text('Belum Waktunya Sesi'), findsOneWidget); + expect(find.text('Mengerti'), findsOneWidget); + await tester.tap(find.text('Mengerti')); + await tester.pumpAndSettle(); + + // 2. Verify offline clinic session shows Maps card & Buka di Maps button + expect(find.byType(ClinicLocationMapCard), findsWidgets); + expect(find.text('Lokasi Konsultasi'), findsWidgets); + expect(find.text('Buka di Maps'), findsOneWidget); + + // 3. Verify Masuk Video Call button is present for active session and tap it + final videoCallBtn = find.text('Masuk Video Call Sekarang'); + expect(videoCallBtn, findsOneWidget); + await tester.tap(videoCallBtn); + await tester.pumpAndSettle(); + + // Verify PsychologistVideoCallScreen is launched + expect(find.byType(PsychologistVideoCallScreen), findsOneWidget); + expect(find.byIcon(Icons.call_end_rounded), findsOneWidget); + expect(find.byIcon(Icons.mic_rounded), findsOneWidget); + expect(find.byIcon(Icons.videocam_rounded), findsOneWidget); + + // 4. End the video call + final endCallBtn = find.byIcon(Icons.call_end_rounded); + await tester.tap(endCallBtn); + await tester.pumpAndSettle(); + + // In confirmation dialog, tap "Ya, Selesaikan" + final confirmEndBtn = find.text('Ya, Selesaikan'); + expect(confirmEndBtn, findsOneWidget); + await tester.tap(confirmEndBtn); + await tester.pumpAndSettle(); + + // 5. Verify ConsultationResultBottomSheet appears directly (not redirected to history) + expect(find.byType(ConsultationResultBottomSheet), findsOneWidget); + expect(find.text('Hasil & Saran Sesi Konsultasi'), findsOneWidget); + expect(find.text('RANGKUMAN HASIL KONSULTASI'), findsOneWidget); + expect(find.text('SARAN & REKOMENDASI PSIKOLOG'), findsOneWidget); + expect(find.text('LATIHAN MANDIRI (ACTION ITEMS)'), findsOneWidget); + + // Tap "Selesai & Tutup Saran Sesi" to close + final closeSheetBtn = find.text('Selesai & Tutup Saran Sesi'); + expect(closeSheetBtn, findsOneWidget); + await tester.tap(closeSheetBtn); + await tester.pumpAndSettle(); + }); + + testWidgets('Tapping Riwayat navigates to ConsultationHistoryScreen and shows hired psychologists & logs', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + final historyBtn = find.text('Riwayat'); + expect(historyBtn, findsOneWidget); + await tester.tap(historyBtn); + await tester.pumpAndSettle(); + + // Verify ConsultationHistoryScreen is displayed + expect(find.byType(ConsultationHistoryScreen), findsOneWidget); + expect(find.text('Riwayat Bicara & Konseling'), findsOneWidget); + expect(find.text('PSIKOLOG YANG PERNAH DISEWA'), findsOneWidget); + + // Verify hired psychologists listed + expect(find.text('Amanda Zahra, M.Psi., Psikolog'), findsOneWidget); + expect(find.text('Dimas Wicaksono, M.Psi., Psikolog'), findsOneWidget); + expect(find.text('Rian Pratama, M.Psi., Psikolog'), findsOneWidget); + + // Tap on Lihat Log & Saran Sesi on Amanda Zahra's card + final lihatLogBtn = find.text('Lihat Log & Saran Sesi').first; + expect(lihatLogBtn, findsOneWidget); + await tester.tap(lihatLogBtn); + await tester.pumpAndSettle(); + + // Verify modal sheet opens with per-session logs and clinical advice + expect(find.text('Saran & Rekomendasi Psikolog:'), findsWidgets); + expect(find.text('Praktikkan teknik relaksasi napas 4-7-8 setiap malam 15 menit sebelum waktu tidur.'), findsOneWidget); + expect(find.text('LATIHAN MANDIRI (ACTION ITEMS)'), findsWidgets); + }); + + testWidgets('PsychologistCertificateCarousel displays certificates one by one and navigates via swipe and chevron buttons', (tester) async { + final certs = [ + 'SIPP: 1988-12-04-2021 (HIMPSI)', + 'Magister Profesi Psikologi Klinis Dewasa (S2)', + 'Sertifikasi CBT Terapan & Regulasi Emosi', + 'Sertifikat Psychological First Aid (PFA) Nasional', + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: PsychologistCertificateCarousel(certifications: certs), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Verify first certificate is shown with photo & clean label (no AI slop) + expect(find.text('1 / 4'), findsOneWidget); + expect(find.text('SIPP: 1988-12-04-2021 (HIMPSI)'), findsOneWidget); + expect(find.text('Perbesar'), findsOneWidget); + + // Tap next chevron button + final nextBtn = find.byIcon(Icons.chevron_right_rounded); + expect(nextBtn, findsOneWidget); + await tester.tap(nextBtn); + await tester.pumpAndSettle(); + + // Verify second certificate is displayed + expect(find.text('2 / 4'), findsOneWidget); + expect(find.text('Magister Profesi Psikologi Klinis Dewasa (S2)'), findsOneWidget); + + // Tap previous chevron button + final prevBtn = find.byIcon(Icons.chevron_left_rounded); + expect(prevBtn, findsOneWidget); + await tester.tap(prevBtn); + await tester.pumpAndSettle(); + + // Verify back to first certificate + expect(find.text('1 / 4'), findsOneWidget); + expect(find.text('SIPP: 1988-12-04-2021 (HIMPSI)'), findsOneWidget); + + // Swipe left on PageView to go to certificate 2 + await tester.drag(find.byType(PageView), const Offset(-300, 0)); + await tester.pumpAndSettle(); + + expect(find.text('2 / 4'), findsOneWidget); + expect(find.text('Magister Profesi Psikologi Klinis Dewasa (S2)'), findsOneWidget); + }); +} diff --git a/apps/eva_mobile/test/features/care/consultation_session_logs_screen_test.dart b/apps/eva_mobile/test/features/care/consultation_session_logs_screen_test.dart new file mode 100644 index 0000000..6812099 --- /dev/null +++ b/apps/eva_mobile/test/features/care/consultation_session_logs_screen_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/core/routes/app_router.dart'; +import 'package:eva_mobile/features/care/domain/entities/consultation_session_entity.dart'; +import 'package:eva_mobile/features/care/domain/entities/psychologist_entity.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_history_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_session_logs_screen.dart'; + +void main() { + setUp(() { + AppConfig.useMockData = true; + SharedPreferences.setMockInitialValues({ + 'user_name': 'Alexander Agung', + 'user_email': 'alex@example.com', + 'user_coin_balance': 120, + }); + }); + + final testPsychologist = PsychologistEntity( + id: 'psy_test_1', + name: 'Dr. Sarah Amalia, M.Psi., Psikolog', + title: 'Psikolog Klinis Dewasa', + registrationNumber: 'STR-1994829102-PK', + avatarUrl: 'https://images.unsplash.com/photo-1594824813637-279c13b2e5a7?auto=format&fit=crop&q=80&w=300', + rating: 4.9, + reviewCount: 142, + experienceYears: 8, + specialties: const ['Depresi', 'Kecemasan', 'Manajemen Stres'], + coinsPerSession: 40, + durationMinutes: 50, + availableOnline: true, + bio: 'Berpengalaman menangani berbagai isu kesehatan mental klinis.', + practitionerType: PractitionerType.professional, + hasPsychologistCertificate: true, + isVolunteerRole: false, + ); + + final testSessions = [ + ConsultationSessionEntity( + id: 'session_test_1', + psychologist: testPsychologist, + sessionDateTime: DateTime(2026, 5, 10, 14, 0), + status: ConsultationStatus.completed, + type: ConsultationType.onlineVideo, + sessionNumber: 1, + durationMinutes: 50, + notes: 'Sering merasa cemas berlebihan dan insomnia menjelang deadline.', + diagnosisSummary: 'Kecemasan situasional tingkat sedang terpicu oleh beban kerja tinggi.', + adviceList: const [ + 'Lakukan teknik pernapasan 4-7-8 setiap kali mulai merasa overwhelmed.', + 'Batasi penggunaan layar minimal 45 menit sebelum tidur malam.', + ], + actionItems: const [ + 'Catat pemicu cemas harian di fitur Buku Harian AI.', + 'Lakukan jalan santai 15 menit setiap pagi.', + ], + moodBefore: 'Cemas & Gelisah', + moodAfter: 'Lebih Tenang & Lega', + ), + ]; + + testWidgets('ConsultationSessionLogsScreen renders title, psychologist card, advice, and bottom CTA', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + onGenerateRoute: AppRouter.onGenerateRoute, + home: ConsultationSessionLogsScreen( + psychologist: testPsychologist, + sessions: testSessions, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Verify Title & Header + expect(find.text('Log & Saran Sesi'), findsOneWidget); + expect(find.text('Catatan klinis & rekomendasi per sesi'), findsOneWidget); + + // Verify Psychologist Overview & chevron icon (no standalone 'Profil' button) + expect(find.text('Dr. Sarah Amalia, M.Psi., Psikolog'), findsOneWidget); + expect(find.text('Psikolog Klinis Dewasa'), findsOneWidget); + expect(find.text('Profil'), findsNothing); + expect(find.byIcon(Icons.chevron_right_rounded), findsOneWidget); + + // Verify Date Filter + expect(find.text('Semua Tanggal (1)'), findsOneWidget); + expect(find.text('10 Mei 2026'), findsOneWidget); + + // Verify Session Details + expect(find.text('Sesi #1'), findsOneWidget); + expect(find.text('50 Menit'), findsOneWidget); + expect(find.text('"Sering merasa cemas berlebihan dan insomnia menjelang deadline."'), findsOneWidget); + expect(find.text('Kecemasan situasional tingkat sedang terpicu oleh beban kerja tinggi.'), findsOneWidget); + expect(find.text('Lakukan teknik pernapasan 4-7-8 setiap kali mulai merasa overwhelmed.'), findsOneWidget); + expect(find.text('Catat pemicu cemas harian di fitur Buku Harian AI.'), findsOneWidget); + + // Verify Bottom Action Button + expect(find.text('Jadwalkan Sesi Baru Bersama Dr. Sarah Amalia'), findsOneWidget); + }); + + testWidgets('ConsultationHistoryScreen navigates to ConsultationSessionLogsScreen when pressing Lihat Log & Saran Sesi', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + onGenerateRoute: AppRouter.onGenerateRoute, + home: ConsultationHistoryScreen(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Verify Screen Title + expect(find.text('Riwayat Bicara & Konseling'), findsOneWidget); + + // Find and tap "Lihat Log & Saran Sesi" + final buttonFinder = find.text('Lihat Log & Saran Sesi'); + expect(buttonFinder, findsWidgets); + + await tester.tap(buttonFinder.first); + await tester.pumpAndSettle(); + + // Verify that we arrived on the new dedicated ConsultationSessionLogsScreen + expect(find.text('Log & Saran Sesi'), findsOneWidget); + expect(find.text('Catatan klinis & rekomendasi per sesi'), findsOneWidget); + }); + + testWidgets('ConsultationHistoryScreen search bar filters psychologists and shows empty state on unmatched query', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + onGenerateRoute: AppRouter.onGenerateRoute, + home: ConsultationHistoryScreen(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Verify search bar is rendered + final searchFinder = find.byType(TextField); + expect(searchFinder, findsOneWidget); + expect(find.text('Cari nama psikolog atau relawan...'), findsOneWidget); + + // Search for a non-existent name + await tester.enterText(searchFinder, 'NamaTidakAdaXYZ'); + await tester.pump(); + + // Verify search empty state + expect(find.text('Tidak Ditemukan'), findsOneWidget); + expect(find.text('Reset Pencarian'), findsOneWidget); + + // Tap reset + await tester.tap(find.text('Reset Pencarian')); + await tester.pump(); + + // Verify items are restored + expect(find.text('Tidak Ditemukan'), findsNothing); + }); +} diff --git a/apps/eva_mobile/test/features/coin/coin_feature_test.dart b/apps/eva_mobile/test/features/coin/coin_feature_test.dart new file mode 100644 index 0000000..2a9a728 --- /dev/null +++ b/apps/eva_mobile/test/features/coin/coin_feature_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:eva_mobile/features/coin/domain/entities/coin_package_entity.dart'; +import 'package:eva_mobile/features/coin/presentation/providers/coin_provider.dart'; +import 'package:eva_mobile/features/coin/presentation/screens/coin_purchase_screen.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/profile_dropdown_modal.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('CoinPackageEntity tests', () { + test('Calculates totalCoins correctly with bonus', () { + const pkg = CoinPackageEntity( + id: 'pkg_test', + title: 'Paket Tes', + coins: 100, + bonusCoins: 25, + priceIdr: 50000, + ); + expect(pkg.totalCoins, 125); + expect(pkg.formattedPrice, 'Rp 50.000'); + }); + + test('Calculates formattedPrice for large amount', () { + const pkg = CoinPackageEntity( + id: 'pkg_large', + title: 'Paket Besar', + coins: 1000, + priceIdr: 250000, + ); + expect(pkg.totalCoins, 1000); + expect(pkg.formattedPrice, 'Rp 250.000'); + }); + }); + + group('CoinBalanceNotifier Provider tests', () { + test('Initializes with default balance and can add/spend coins', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(coinBalanceProvider), kDefaultCoinBalance); + + // Add coins + await container.read(coinBalanceProvider.notifier).addCoins(50); + expect(container.read(coinBalanceProvider), kDefaultCoinBalance + 50); + + // Spend coins + final success = await container.read(coinBalanceProvider.notifier).spendCoins(30); + expect(success, isTrue); + expect(container.read(coinBalanceProvider), kDefaultCoinBalance + 20); + + // Insufficient spend + final fail = await container.read(coinBalanceProvider.notifier).spendCoins(99999); + expect(fail, isFalse); + expect(container.read(coinBalanceProvider), kDefaultCoinBalance + 20); + }); + }); + + group('ProfileDropdownModal Widget tests', () { + testWidgets('Displays coin balance with purple theme and triggers onCoinTap', (tester) async { + bool coinTapped = false; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: ProfileDropdownModal( + userName: 'Samsul Arifin', + userEmail: 'samsul@example.com', + coinBalance: 250, + onProfileTap: () {}, + onEmergencyContactsTap: () {}, + onSettingsTap: () {}, + onLogoutTap: () {}, + onCoinTap: () { + coinTapped = true; + }, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify user info is displayed + expect(find.text('Samsul Arifin'), findsOneWidget); + + // Verify coin balance is shown instead of Upgrade profile + expect(find.text('250 Koin'), findsOneWidget); + expect(find.text('Saldo Koin'), findsOneWidget); + expect(find.text('Beli'), findsOneWidget); + expect(find.text('Upgrade profile'), findsNothing); + + // Tap on the coin banner + await tester.tap(find.text('250 Koin')); + await tester.pumpAndSettle(); + + expect(coinTapped, isTrue); + }); + }); + + group('CoinPurchaseScreen Widget tests', () { + testWidgets('Renders balance, packages, payment methods, and purchase button', (tester) async { + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: CoinPurchaseScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Check header + expect(find.text('Beli Koin EVA'), findsOneWidget); + + // Check default balance hero card + expect(find.text('$kDefaultCoinBalance Koin'), findsOneWidget); + + // Check packages + expect(find.text('Paket Starter'), findsOneWidget); + expect(find.text('Paket Favorit'), findsOneWidget); + expect(find.text('Paket Ultimate'), findsOneWidget); + + // Check updated benefits + expect(find.text('Bisa Berbicara Langsung dengan EVA'), findsOneWidget); + expect(find.text('Alat Transaksi Konsultasi Psikolog'), findsOneWidget); + + // Check purchase button + expect(find.text('Beli Sekarang'), findsOneWidget); + }); + }); +} diff --git a/apps/eva_mobile/test/features/monitoring/monitoring_screen_test.dart b/apps/eva_mobile/test/features/monitoring/monitoring_screen_test.dart index e72430e..7393f9a 100644 --- a/apps/eva_mobile/test/features/monitoring/monitoring_screen_test.dart +++ b/apps/eva_mobile/test/features/monitoring/monitoring_screen_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:eva_mobile/features/monitoring/data/models/monitoring_data_model.dart'; import 'package:eva_mobile/features/monitoring/domain/entities/monitoring_data_entity.dart'; import 'package:eva_mobile/features/monitoring/presentation/providers/monitoring_provider.dart'; -import 'package:eva_mobile/screens/monitoring_screen.dart'; +import 'package:eva_mobile/features/monitoring/presentation/screens/monitoring_screen.dart'; void main() { group('MonitoringDataModel Tests', () { diff --git a/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart b/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart new file mode 100644 index 0000000..6782c6b --- /dev/null +++ b/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/branded_guidance_card.dart'; + +void main() { + group('BrandedGuidanceCard', () { + testWidgets('renders branded card with typography, badge, title, and description', + (tester) async { + bool skipped = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: BrandedGuidanceCard( + title: 'Cerita ke Eva, yuk!', + description: + 'Tekan tombol tengah untuk mulai berbicara. Kamu bisa mulai saat sudah siap.', + actionHint: 'Ketuk tombol di bawah untuk mencoba', + badgeText: 'EVA AI', + onSkip: () => skipped = true, + ), + ), + ), + ), + ); + + // Verify title & description + expect(find.text('Cerita ke Eva, yuk!'), findsOneWidget); + expect( + find.text( + 'Tekan tombol tengah untuk mulai berbicara. Kamu bisa mulai saat sudah siap.'), + findsOneWidget, + ); + expect(find.text('EVA AI'), findsOneWidget); + expect(find.text('Ketuk tombol di bawah untuk mencoba'), findsOneWidget); + expect(find.text('Lewati'), findsOneWidget); + + // Tap "Lewati" + await tester.tap(find.text('Lewati')); + expect(skipped, isTrue); + }); + + testWidgets('renders Lanjut button when isInteractiveTarget is false', + (tester) async { + bool nextClicked = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: BrandedGuidanceCard( + title: 'Jurnal Refleksi Otomatis', + description: + 'Setiap selesai bercerita, intisari renunganmu tersimpan di sini.', + isInteractiveTarget: false, + onSkip: () {}, + onNext: () => nextClicked = true, + ), + ), + ), + ), + ); + + expect(find.text('Lanjut'), findsOneWidget); + await tester.tap(find.text('Lanjut')); + expect(nextClicked, isTrue); + }); + + testWidgets('renders custom Selesai button when nextButtonLabel is Selesai', + (tester) async { + bool finishClicked = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: BrandedGuidanceCard( + title: 'Layanan & Dukungan Sebaya', + description: + 'Jika butuh teman bicara manusia, temukan konseling profesional di sini.', + isInteractiveTarget: false, + nextButtonLabel: 'Selesai', + onSkip: () {}, + onNext: () => finishClicked = true, + ), + ), + ), + ), + ); + + expect(find.text('Selesai'), findsOneWidget); + await tester.tap(find.text('Selesai')); + expect(finishClicked, isTrue); + }); + }); +} diff --git a/apps/eva_mobile/test/features/tutorial/interactive_spotlight_overlay_test.dart b/apps/eva_mobile/test/features/tutorial/interactive_spotlight_overlay_test.dart new file mode 100644 index 0000000..1b5ba04 --- /dev/null +++ b/apps/eva_mobile/test/features/tutorial/interactive_spotlight_overlay_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; + +void main() { + group('InteractiveSpotlightOverlay', () { + testWidgets('allows tap through target cutout while blocking background taps', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + bool targetTapped = false; + bool backgroundTapped = false; + bool skipped = false; + + final targetKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Stack( + children: [ + // 1. Background button that SHOULD BE BLOCKED + Positioned( + top: 100, + left: 50, + child: ElevatedButton( + onPressed: () => backgroundTapped = true, + child: const Text('Background Button'), + ), + ), + + // 2. Target button that SHOULD BE REACHABLE + Positioned( + bottom: 50, + left: 170, + child: GestureDetector( + key: targetKey, + onTap: () => targetTapped = true, + child: Container( + width: 60, + height: 60, + color: Colors.blue, + child: const Center(child: Text('Target')), + ), + ), + ), + + // 3. Spotlight Overlay + InteractiveSpotlightOverlay( + targetKey: targetKey, + content: TutorialContent.stepContents[TutorialStep.voiceSession]!, + onSkip: () => skipped = true, + onTargetTap: () => targetTapped = true, + ), + ], + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Check card content rendered + expect(find.text('Cerita ke Eva, yuk!'), findsOneWidget); + + // Attempt tapping the background button outside the cutout + await tester.tap(find.text('Background Button')); + await tester.pump(); + expect( + backgroundTapped, + isFalse, + reason: 'Background button must be blocked by the dimmed overlay barrier', + ); + + // Tap the target through the cutout hole + await tester.tap(find.text('Target')); + await tester.pump(); + expect( + targetTapped, + isTrue, + reason: 'Target button must receive touch events directly through the cutout', + ); + + // Tap Lewati + await tester.tap(find.text('Lewati')); + expect(skipped, isTrue); + }); + }); +} diff --git a/apps/eva_mobile/test/features/tutorial/main_shell_tutorial_test.dart b/apps/eva_mobile/test/features/tutorial/main_shell_tutorial_test.dart new file mode 100644 index 0000000..b6448bd --- /dev/null +++ b/apps/eva_mobile/test/features/tutorial/main_shell_tutorial_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:eva_mobile/features/home/presentation/screens/main_shell_screen.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('MainShellScreen Tutorial Integration', () { + testWidgets( + 'displays spotlight overlay on center button and navigates when center button is tapped', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + bool chatRoutePushed = false; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + routes: { + '/chat': (context) { + chatRoutePushed = true; + return const Scaffold(body: Text('Chat Screen Destination')); + }, + }, + home: const MainShellScreen(initialIndex: 0), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // 1. Overlay is visible with Eva tutorial card + expect(find.byType(InteractiveSpotlightOverlay), findsOneWidget); + expect(find.text('Cerita ke Eva, yuk!'), findsOneWidget); + expect(find.text('REKOMENDASI EVA'), findsOneWidget); + + // 2. Tap the central action button (Icons.auto_awesome) directly through the spotlight cutout + await tester.tap(find.byIcon(Icons.auto_awesome).last); + await tester.pumpAndSettle(); + + // 3. Navigation was triggered + expect(chatRoutePushed, isTrue); + expect(find.text('Chat Screen Destination'), findsOneWidget); + }); + + testWidgets('dismisses spotlight overlay when Lewati is tapped', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: MainShellScreen(initialIndex: 0), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(InteractiveSpotlightOverlay), findsOneWidget); + + // Tap Lewati + await tester.tap(find.text('Lewati')); + await tester.pumpAndSettle(); + + // Overlay dismissed + expect(find.byType(InteractiveSpotlightOverlay), findsNothing); + }); + }); +} diff --git a/apps/eva_mobile/test/features/tutorial/tutorial_provider_test.dart b/apps/eva_mobile/test/features/tutorial/tutorial_provider_test.dart new file mode 100644 index 0000000..dd3d719 --- /dev/null +++ b/apps/eva_mobile/test/features/tutorial/tutorial_provider_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/domain/repositories/tutorial_repository.dart'; +import 'package:eva_mobile/features/tutorial/presentation/providers/tutorial_provider.dart'; + +class _FakeTutorialRepository implements TutorialRepository { + bool _completed = false; + + @override + Future<bool> hasCompletedTutorial() async => _completed; + + @override + Future<void> setTutorialCompleted(bool completed) async { + _completed = completed; + } + + @override + Future<void> resetTutorial() async { + _completed = false; + } +} + +void main() { + group('TutorialNotifier', () { + test('initializes and activates for new users', () async { + final repo = _FakeTutorialRepository(); + final container = ProviderContainer( + overrides: [ + tutorialRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + + // Wait for checkInitialStatus + await Future<void>.delayed(const Duration(milliseconds: 10)); + + final state = container.read(tutorialNotifierProvider); + expect(state.isInitialized, isTrue); + expect(state.isActive, isTrue); + expect(state.currentStep, TutorialStep.voiceSession); + }); + + test('advances through steps until completed', () async { + final repo = _FakeTutorialRepository(); + final container = ProviderContainer( + overrides: [ + tutorialRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + + await Future<void>.delayed(const Duration(milliseconds: 10)); + final notifier = container.read(tutorialNotifierProvider.notifier); + + // Voice session target tapped -> sets step to voiceCoins for phone coin tutorial + await notifier.onTargetTapped(TutorialStep.voiceSession); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.voiceCoins); + expect(container.read(tutorialNotifierProvider).isActive, isTrue); + + // Voice coins acknowledged -> sets step to voiceStart + await notifier.onTargetTapped(TutorialStep.voiceCoins); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.voiceStart); + expect(container.read(tutorialNotifierProvider).isActive, isTrue); + + // Voice start tapped -> pauses overlay for active phone call + await notifier.onTargetTapped(TutorialStep.voiceStart); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.voiceCompleted); + expect(container.read(tutorialNotifierProvider).isActive, isFalse); + + // Voice completed (user taps Lihat Detail Jurnal Refleksi) -> sets step to journal + await notifier.onTargetTapped(TutorialStep.voiceCompleted); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.journal); + expect(container.read(tutorialNotifierProvider).isActive, isTrue); + + // Next step -> analytics + notifier.nextStep(); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.analytics); + + // Next step -> dassDetail + notifier.nextStep(); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.dassDetail); + + // Next step -> dassItem + notifier.nextStep(); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.dassItem); + + // Next step -> care + notifier.nextStep(); + expect( + container.read(tutorialNotifierProvider).currentStep, TutorialStep.care); + + // Next step -> completed + notifier.nextStep(); + expect(container.read(tutorialNotifierProvider).isCompleted, isTrue); + expect(container.read(tutorialNotifierProvider).isActive, isFalse); + }); + + test('onTargetTapped advances through full flow including dassDetail and care', () async { + final repo = _FakeTutorialRepository(); + final container = ProviderContainer( + overrides: [ + tutorialRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + + await Future<void>.delayed(const Duration(milliseconds: 10)); + final notifier = container.read(tutorialNotifierProvider.notifier); + + await notifier.onTargetTapped(TutorialStep.voiceSession); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.voiceCoins); + + await notifier.onTargetTapped(TutorialStep.voiceCoins); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.voiceStart); + + await notifier.onTargetTapped(TutorialStep.voiceStart); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.voiceCompleted); + + await notifier.onTargetTapped(TutorialStep.voiceCompleted); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.journal); + + await notifier.onTargetTapped(TutorialStep.journal); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.analytics); + + await notifier.onTargetTapped(TutorialStep.analytics); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.dassDetail); + + await notifier.onTargetTapped(TutorialStep.dassDetail); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.dassItem); + + await notifier.onTargetTapped(TutorialStep.dassItem); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.care); + + await notifier.onTargetTapped(TutorialStep.care); + expect(container.read(tutorialNotifierProvider).isCompleted, isTrue); + expect(container.read(tutorialNotifierProvider).isActive, isFalse); + }); + + test('skipTutorial dismisses for current session, ensureActiveOnLaunch resets on launch', () async { + final repo = _FakeTutorialRepository(); + final container = ProviderContainer( + overrides: [ + tutorialRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + + await Future<void>.delayed(const Duration(milliseconds: 10)); + final notifier = container.read(tutorialNotifierProvider.notifier); + + await notifier.skipTutorial(); + expect(container.read(tutorialNotifierProvider).isCompleted, isTrue); + expect(container.read(tutorialNotifierProvider).isActive, isFalse); + + // On app reopen or restart, ensureActiveOnLaunch displays recommendation again + notifier.ensureActiveOnLaunch(); + expect(container.read(tutorialNotifierProvider).isActive, isTrue); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.voiceSession); + }); + }); +} diff --git a/apps/eva_mobile/test/features/voice_call/ai_call_controller_test.dart b/apps/eva_mobile/test/features/voice_call/ai_call_controller_test.dart index 8936e9f..59ea999 100644 --- a/apps/eva_mobile/test/features/voice_call/ai_call_controller_test.dart +++ b/apps/eva_mobile/test/features/voice_call/ai_call_controller_test.dart @@ -344,11 +344,11 @@ void main() { controller.handleSocketEvent({ 'type': 'turn.committed', - 'final_transcript': 'halo luna apa kabar', + 'final_transcript': 'halo eva apa kabar', }); expect(controller.state.callState, CallState.thinking); - expect(controller.state.currentTranscript, 'halo luna apa kabar'); + expect(controller.state.currentTranscript, 'halo eva apa kabar'); expect(fakeSpeech.isListening, isFalse); }); diff --git a/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart b/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart index b678800..fa16f54 100644 --- a/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart +++ b/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart @@ -1,9 +1,30 @@ +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/screens/ai_conversation_screen.dart'; +import 'package:eva_mobile/features/chat/domain/entities/conversation_entity.dart'; +import 'package:eva_mobile/features/chat/domain/repositories/chat_repository.dart'; +import 'package:eva_mobile/features/chat/presentation/providers/chat_provider.dart'; +import 'package:eva_mobile/features/chat/presentation/screens/ai_conversation_screen.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/presentation/providers/tutorial_provider.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; import 'package:eva_mobile/widgets/glass_card.dart'; +class _PendingChatRepository implements ChatRepository { + @override + Future<List<ConversationEntity>> getConversations() => + Completer<List<ConversationEntity>>().future; + + @override + Future<ConversationEntity?> getConversationById(String id) => + Completer<ConversationEntity?>().future; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + void main() { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -16,8 +37,10 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const MaterialApp( - home: AiConversationScreen(), + const ProviderScope( + child: MaterialApp( + home: AiConversationScreen(), + ), ), ); @@ -37,8 +60,10 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const MaterialApp( - home: AiConversationScreen(), + const ProviderScope( + child: MaterialApp( + home: AiConversationScreen(), + ), ), ); @@ -59,8 +84,13 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const MaterialApp( - home: AiConversationScreen(), + ProviderScope( + overrides: [ + chatRepositoryProvider.overrideWithValue(_PendingChatRepository()), + ], + child: const MaterialApp( + home: AiConversationScreen(), + ), ), ); @@ -76,5 +106,73 @@ void main() { ); expect(skeletonFinder, findsNWidgets(10)); }); + + testWidgets('renders coin balance badge with + icon and no logo icon to the left of title', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: AiConversationScreen(), + ), + ), + ); + + await tester.pump(); + + // Verify title exists + expect(find.text('Sesi Suara EVA'), findsOneWidget); + + // Verify nightlight icon to the left of the title does not exist + expect(find.byIcon(Icons.nightlight_round), findsNothing); + + // Verify coin badge with default coin balance (120) and + icon + expect(find.text('120'), findsOneWidget); + expect(find.byIcon(Icons.monetization_on_rounded), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget); + }); + + testWidgets('shows tutorial spotlight on coin badge at TutorialStep.voiceCoins and moves to Mulai Sesi Suara', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final container = ProviderContainer(); + addTearDown(container.dispose); + + // Set tutorial to voiceCoins step + container.read(tutorialNotifierProvider.notifier).resumeAtStep(TutorialStep.voiceCoins); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: AiConversationScreen(), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Verify spotlight overlay for Telepon Pakai Koin is visible + expect(find.byType(InteractiveSpotlightOverlay), findsOneWidget); + expect(find.text('Telepon Pakai Koin'), findsOneWidget); + expect(find.text('INFO KOIN'), findsOneWidget); + + // Advance to next step (Mulai Sesi Suara) + await tester.tap(find.text('Lanjut')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Verify spotlight overlay advanced to Mulai Sesi Suara + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.voiceStart); + expect(find.text('Mulai Sesi Suara'), findsWidgets); + expect(find.text('SESI SUARA'), findsOneWidget); + }); } diff --git a/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart b/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart index 243950c..ea927c7 100644 --- a/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart +++ b/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/screens/ai_diary_detail_screen.dart'; +import 'package:eva_mobile/features/diary/presentation/screens/ai_diary_detail_screen.dart'; void main() { setUp(() { @@ -40,7 +40,7 @@ void main() { { 'isUser': true, 'time': '09:15 AM', - 'text': 'Halo Luna, aku merasa lelah dengan deadline pekerjaan.', + 'text': 'Halo Eva, aku merasa lelah dengan deadline pekerjaan.', 'emotionTag': 'lelah (80%)', 'emotionEmoji': '💥', }, @@ -85,7 +85,7 @@ void main() { expect(find.text('09:16 AM'), findsNothing); // Transcript message text is present - expect(find.text('Halo Luna, aku merasa lelah dengan deadline pekerjaan.'), findsOneWidget); + expect(find.text('Halo Eva, aku merasa lelah dengan deadline pekerjaan.'), findsOneWidget); expect(find.text('Aku mendengarkanmu. Mari kita urai bebanmu satu per satu.'), findsOneWidget); }); @@ -115,7 +115,7 @@ void main() { 'moodTag': 'Tenang', 'moodEmoji': '😌', 'transcripts': [ - {'isUser': true, 'time': '08:00 AM', 'text': 'Pagi Luna'} + {'isUser': true, 'time': '08:00 AM', 'text': 'Pagi Eva'} ], }, { @@ -125,7 +125,7 @@ void main() { 'moodTag': 'Lega', 'moodEmoji': '😃', 'transcripts': [ - {'isUser': true, 'time': '05:00 PM', 'text': 'Sore Luna'} + {'isUser': true, 'time': '05:00 PM', 'text': 'Sore Eva'} ], }, ], diff --git a/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart b/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart index 36a1d7c..b25e8cb 100644 --- a/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart +++ b/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/core/config/app_config.dart'; -import 'package:eva_mobile/screens/dass_assessment_screen.dart'; +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/features/dass/presentation/screens/dass_assessment_screen.dart'; void main() { setUp(() { diff --git a/apps/eva_mobile/test/screens/login_screen_test.dart b/apps/eva_mobile/test/screens/login_screen_test.dart new file mode 100644 index 0000000..1a76dbc --- /dev/null +++ b/apps/eva_mobile/test/screens/login_screen_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/login_screen.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('LoginScreen Demo Autofill Tests', () { + testWidgets('renders demo autofill button and populates seeded credentials when tapped', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const MaterialApp( + home: LoginScreen(), + ), + ); + + await tester.pumpAndSettle(); + + // Verify the demo autofill button is present + final demoButton = find.byKey(const Key('login_demo_autofill_button')); + expect(demoButton, findsOneWidget); + expect(find.text('Gunakan Akun Demo'), findsOneWidget); + + // Verify email and password fields are initially empty + expect(find.text(AppConfig.demoEmail), findsNothing); + expect(find.text(AppConfig.demoPassword), findsNothing); + + // Tap the demo autofill button + await tester.tap(demoButton); + await tester.pump(); + + // Verify fields now contain the seeded demo credentials + expect(find.text(AppConfig.demoEmail), findsOneWidget); + expect(find.text(AppConfig.demoPassword), findsOneWidget); + + // Verify confirmation SnackBar is displayed + expect( + find.text('Akun demo berhasil diisi (${AppConfig.demoEmail})'), + findsOneWidget, + ); + }); + }); +} diff --git a/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart b/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart index 460081f..32c91d6 100644 --- a/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart +++ b/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart @@ -3,8 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:eva_mobile/features/voice_call/presentation/providers/ai_call_provider.dart'; -import 'package:eva_mobile/screens/main_shell_screen.dart'; -import 'package:eva_mobile/screens/voice_call_screen.dart'; +import 'package:eva_mobile/features/home/presentation/screens/main_shell_screen.dart'; +import 'package:eva_mobile/features/voice_call/presentation/screens/voice_call_screen.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; import 'package:eva_mobile/widgets/floating_nav_bar.dart'; void main() { @@ -75,7 +76,8 @@ void main() { ); await tester.tap(find.text('Go Home Tab 1')); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); expect(find.byType(MainShellScreen), findsOneWidget); expect(find.byType(FloatingNavBar), findsOneWidget); @@ -161,5 +163,51 @@ void main() { expect(receivedArgs, isNotNull); expect(receivedArgs!['initialIndex'], 1); }); + + testWidgets('shows interactive spotlight overlay on Lihat Detail Jurnal Refleksi during tutorial', + (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + Map<String, dynamic>? receivedArgs; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + routes: { + '/home': (context) { + receivedArgs = ModalRoute.of(context)?.settings.arguments + as Map<String, dynamic>?; + return const Scaffold(body: Text('Home Screen Destination')); + }, + }, + home: const VoiceCallScreen(), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // End call + await tester.tap(find.byIcon(Icons.call_end)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + + // Spotlight overlay is present on the bottom sheet + expect(find.byType(InteractiveSpotlightOverlay), findsOneWidget); + expect(find.text('Refleksi Sesi Suara'), findsOneWidget); + expect(find.text('REFLEKSI AI'), findsOneWidget); + + // Tap "Lihat Detail Jurnal Refleksi" + await tester.tap(find.text('Lihat Detail Jurnal Refleksi')); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Home Screen Destination'), findsOneWidget); + expect(receivedArgs!['initialIndex'], 1); + }); }); } diff --git a/apps/eva_mobile/test/screens/settings_screen_test.dart b/apps/eva_mobile/test/screens/settings_screen_test.dart index 97d783e..36d6f30 100644 --- a/apps/eva_mobile/test/screens/settings_screen_test.dart +++ b/apps/eva_mobile/test/screens/settings_screen_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/screens/settings_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/settings_screen.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/apps/eva_mobile/test/screens/splash_onboarding_screen_test.dart b/apps/eva_mobile/test/screens/splash_onboarding_screen_test.dart index f740f58..a1752ea 100644 --- a/apps/eva_mobile/test/screens/splash_onboarding_screen_test.dart +++ b/apps/eva_mobile/test/screens/splash_onboarding_screen_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/screens/splash_onboarding_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/splash_onboarding_screen.dart'; void main() { setUp(() { diff --git a/apps/eva_mobile/test/widgets/app_back_button_test.dart b/apps/eva_mobile/test/widgets/app_back_button_test.dart new file mode 100644 index 0000000..7396877 --- /dev/null +++ b/apps/eva_mobile/test/widgets/app_back_button_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:eva_mobile/widgets/app_back_button.dart'; + +void main() { + group('AppBackButton Widget Tests', () { + testWidgets('renders default AppBackButton with rounded back icon and circle shape', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AppBackButton(), + ), + ), + ); + + final iconFinder = find.byIcon(Icons.arrow_back_rounded); + expect(iconFinder, findsOneWidget); + + final containerFinder = find.byType(Container); + expect(containerFinder, findsWidgets); + + final containerWidget = tester.widget<Container>( + find.descendant(of: find.byType(AppBackButton), matching: find.byType(Container)), + ); + final decoration = containerWidget.decoration as BoxDecoration; + expect(decoration.shape, BoxShape.circle); + + final iconWidget = tester.widget<Icon>(iconFinder); + expect(iconWidget.size, 20.0); + }); + + testWidgets('calls Navigator.maybePop when tapped without custom onPressed', (tester) async { + bool didPop = false; + + await tester.pumpWidget( + MaterialApp( + home: Navigator( + onDidRemovePage: (page) { + didPop = true; + }, + pages: const [ + MaterialPage(child: Scaffold(body: Text('Page 1'))), + MaterialPage(child: Scaffold(body: AppBackButton())), + ], + ), + ), + ); + + await tester.tap(find.byType(AppBackButton)); + await tester.pumpAndSettle(); + + expect(didPop, isTrue); + }); + + testWidgets('calls custom onPressed callback when supplied', (tester) async { + bool pressed = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AppBackButton( + onPressed: () => pressed = true, + ), + ), + ), + ); + + await tester.tap(find.byType(AppBackButton)); + await tester.pump(); + + expect(pressed, isTrue); + }); + + testWidgets('AppBackButton.dark renders with white icon and appropriate dark decoration', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AppBackButton.dark(), + ), + ), + ); + + final iconWidget = tester.widget<Icon>(find.byIcon(Icons.arrow_back_rounded)); + expect(iconWidget.color, Colors.white); + + final containerWidget = tester.widget<Container>( + find.descendant(of: find.byType(AppBackButton), matching: find.byType(Container)), + ); + final decoration = containerWidget.decoration as BoxDecoration; + expect(decoration.shape, BoxShape.circle); + }); + + testWidgets('respects custom size, icon, and tooltip', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AppBackButton( + size: 48.0, + iconSize: 24.0, + icon: Icons.close_rounded, + tooltip: 'Tutup', + ), + ), + ), + ); + + expect(find.byIcon(Icons.close_rounded), findsOneWidget); + expect(find.byTooltip('Tutup'), findsOneWidget); + + final containerWidget = tester.widget<Container>( + find.descendant(of: find.byType(AppBackButton), matching: find.byType(Container)), + ); + expect(containerWidget.constraints?.maxWidth, 48.0); + expect(containerWidget.constraints?.maxHeight, 48.0); + }); + }); +} diff --git a/apps/eva_mobile/test/widgets/app_snack_bar_test.dart b/apps/eva_mobile/test/widgets/app_snack_bar_test.dart new file mode 100644 index 0000000..694424f --- /dev/null +++ b/apps/eva_mobile/test/widgets/app_snack_bar_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AppSnackBar Widget Tests', () { + testWidgets('renders primary variant with correct styling and icon', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + AppSnackBar.showPrimary(context, 'Pesan Primary'); + }, + child: const Text('Show'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Show')); + await tester.pump(); + + expect(find.text('Pesan Primary'), findsOneWidget); + expect(find.byIcon(Icons.auto_awesome_rounded), findsOneWidget); + + final snackBar = tester.widget<SnackBar>(find.byType(SnackBar)); + expect(snackBar.behavior, SnackBarBehavior.floating); + expect(snackBar.backgroundColor, AppColors.primary); + }); + + testWidgets('renders success, warning, and danger variants with respective colors', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => Column( + children: [ + ElevatedButton( + onPressed: () => AppSnackBar.showSuccess(context, 'Sukses Simpan'), + child: const Text('Success'), + ), + ElevatedButton( + onPressed: () => AppSnackBar.showWarning(context, 'Peringatan Form'), + child: const Text('Warning'), + ), + ElevatedButton( + onPressed: () => AppSnackBar.showError(context, 'Terjadi Kesalahan'), + child: const Text('Danger'), + ), + ], + ), + ), + ), + ), + ); + + // Test Success + await tester.tap(find.text('Success')); + await tester.pump(); + expect(find.text('Sukses Simpan'), findsOneWidget); + expect(find.byIcon(Icons.check_circle_outline_rounded), findsOneWidget); + var snackBar = tester.widget<SnackBar>(find.byType(SnackBar)); + expect(snackBar.backgroundColor, AppColors.success); + + // Test Warning (replaces previous snackbar immediately) + await tester.tap(find.text('Warning')); + await tester.pump(); + expect(find.text('Peringatan Form'), findsOneWidget); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + snackBar = tester.widget<SnackBar>(find.byType(SnackBar)); + expect(snackBar.backgroundColor, const Color(0xFFF59E0B)); + + // Test Danger + await tester.tap(find.text('Danger')); + await tester.pump(); + expect(find.text('Terjadi Kesalahan'), findsOneWidget); + expect(find.byIcon(Icons.error_outline_rounded), findsOneWidget); + snackBar = tester.widget<SnackBar>(find.byType(SnackBar)); + expect(snackBar.backgroundColor, const Color(0xFFDC2626)); + }); + + testWidgets('supports custom leading widget and showWithMessenger', (tester) async { + late ScaffoldMessengerState messenger; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + messenger = ScaffoldMessenger.of(context); + return ElevatedButton( + onPressed: () { + AppSnackBar.showWithMessenger( + messenger, + message: 'Sedang Proses...', + variant: SnackBarVariant.primary, + leading: const SizedBox( + key: Key('custom_spinner'), + width: 16, + height: 16, + ), + ); + }, + child: const Text('Custom'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Custom')); + await tester.pump(); + + expect(find.text('Sedang Proses...'), findsOneWidget); + expect(find.byKey(const Key('custom_spinner')), findsOneWidget); + }); + }); +} diff --git a/apps/eva_mobile/test/widgets/floating_nav_bar_test.dart b/apps/eva_mobile/test/widgets/floating_nav_bar_test.dart index 5600be8..966ad16 100644 --- a/apps/eva_mobile/test/widgets/floating_nav_bar_test.dart +++ b/apps/eva_mobile/test/widgets/floating_nav_bar_test.dart @@ -21,7 +21,7 @@ void main() { expect(find.text('Beranda'), findsOneWidget); expect(find.text('Jurnal'), findsOneWidget); expect(find.text('Tren'), findsOneWidget); - expect(find.text('Profil'), findsOneWidget); + expect(find.text('Layanan'), findsOneWidget); expect(find.byIcon(Icons.auto_awesome), findsOneWidget); // Center FAB // Verify AnimatedPositioned pill exists @@ -40,6 +40,10 @@ void main() { await tester.tap(find.text('Tren')); expect(tappedIndex, 2); + // Tap on Layanan (Index 3) + await tester.tap(find.text('Layanan')); + expect(tappedIndex, 3); + // Re-render with currentIndex: 3 await tester.pumpWidget( MaterialApp( @@ -54,8 +58,8 @@ void main() { await tester.pumpAndSettle(); final pillBox = tester.getRect(find.byType(AnimatedPositioned)); - final iconBox = tester.getRect(find.byIcon(Icons.person)); - final textBox = tester.getRect(find.text('Profil')); + final iconBox = tester.getRect(find.byIcon(Icons.volunteer_activism)); + final textBox = tester.getRect(find.text('Layanan')); // Verify vertical centering of item column inside sliding pill final pillCenterY = pillBox.center.dy; diff --git a/apps/eva_mobile/test/widgets/home_screen_test.dart b/apps/eva_mobile/test/widgets/home_screen_test.dart index fc7a196..16d1fb5 100644 --- a/apps/eva_mobile/test/widgets/home_screen_test.dart +++ b/apps/eva_mobile/test/widgets/home_screen_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/screens/home_screen.dart'; +import 'package:eva_mobile/features/home/presentation/screens/home_screen.dart'; void main() { testWidgets( @@ -38,8 +38,8 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - // 1. Brand Header - expect(find.text('EVA'), findsOneWidget); + // 1. Brand Header removed per UI design + expect(find.text('EVA'), findsNothing); // 2. Greeting & Insight Card expect(find.textContaining('Halo, '), findsOneWidget); @@ -91,5 +91,36 @@ void main() { await tester.tap(find.text('Jurnal Refleksi AI')); expect(navigatedTab, 1); }); + + testWidgets('Tapping profile avatar opens dropdown modal with items', (WidgetTester tester) async { + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: HomeScreen(), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Find profile avatar container with circle shape + final avatarFinder = find.byWidgetPredicate( + (widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).shape == BoxShape.circle, + ); + expect(avatarFinder, findsWidgets); + + await tester.tap(avatarFinder.first); + await tester.pumpAndSettle(); + + // Verify modal dropdown items from Gambar 2 + expect(find.text('User Profile'), findsOneWidget); + expect(find.text('Kontak Darurat'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + expect(find.text('Keluar Akun'), findsOneWidget); + }); } diff --git a/apps/eva_mobile/test/widgets/luna_loading_orb_test.dart b/apps/eva_mobile/test/widgets/luna_loading_orb_test.dart deleted file mode 100644 index f57ec9d..0000000 --- a/apps/eva_mobile/test/widgets/luna_loading_orb_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:eva_mobile/widgets/luna_loading_orb.dart'; - -void main() { - testWidgets('LunaLoadingOrb renders with halo and custom status message', (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: LunaLoadingOrb( - size: 200, - message: 'Memuat ruang tenangmu...', - showHalo: true, - ), - ), - ), - ); - - expect(find.byType(LunaLoadingOrb), findsOneWidget); - expect(find.text('Memuat ruang tenangmu...'), findsOneWidget); - - // Pump animated frames - await tester.pump(const Duration(milliseconds: 300)); - await tester.pump(const Duration(milliseconds: 600)); - - expect(find.byType(LunaLoadingOrb), findsOneWidget); - }); -} diff --git a/packages/ai/providers/tts/edge_tts_provider.py b/packages/ai/providers/tts/edge_tts_provider.py index 1c822a0..a543c77 100644 --- a/packages/ai/providers/tts/edge_tts_provider.py +++ b/packages/ai/providers/tts/edge_tts_provider.py @@ -30,7 +30,10 @@ async def synthesize_with_envelope( voice_id: str | None = None, voice_settings: dict[str, float] | None = None, ) -> tuple[bytes, list[float]]: + # EdgeTTS requires voices like 'id-ID-GadisNeural'. If an ElevenLabs voice ID or invalid voice is passed, fallback to default. voice = voice_id or self.default_voice + if "-" not in voice or not voice.endswith("Neural"): + voice = self.default_voice try: import edge_tts except ImportError: @@ -87,6 +90,8 @@ async def synthesize_stream( self, text_stream: AsyncGenerator[str, None], voice_id: str | None = None ) -> AsyncGenerator[bytes, None]: voice = voice_id or self.default_voice + if "-" not in voice or not voice.endswith("Neural"): + voice = self.default_voice try: import edge_tts except ImportError: diff --git a/scripts/agentic/gh_project.py b/scripts/agentic/gh_project.py index 8795772..2daabdd 100755 --- a/scripts/agentic/gh_project.py +++ b/scripts/agentic/gh_project.py @@ -220,6 +220,115 @@ def view_project(project_number: int = DEFAULT_PROJECT_NUMBER, owner: str = DEFA else: print(f"Error viewing project #{project_number}: {res.stderr.strip()}", file=sys.stderr) +def edit_issue( + issue_number: int, + title: Optional[str] = None, + body: Optional[str] = None, + add_labels: Optional[List[str]] = None, + remove_labels: Optional[List[str]] = None, + add_assignees: Optional[List[str]] = None, + remove_assignees: Optional[List[str]] = None, + repo: str = f"{DEFAULT_ORG}/{DEFAULT_REPO}", + dry_run: bool = False, +) -> Dict[str, Any]: + """Edits an existing GitHub issue.""" + if dry_run: + print(f"\n--- DRY-RUN: GitHub Issue #{issue_number} Edit ---") + print(f"Repository: {repo}") + print(f"Issue Number: #{issue_number}") + if title: + print(f"New Title: {title}") + if body: + print(f"New Body:\n{body}\n") + if add_labels: + print(f"Add Labels: {', '.join(add_labels)}") + if remove_labels: + print(f"Remove Labels: {', '.join(remove_labels)}") + if add_assignees: + print(f"Add Assignees: {', '.join(add_assignees)}") + if remove_assignees: + print(f"Remove Assignees: {', '.join(remove_assignees)}") + print("--------------------------------------------------") + return {"status": "dry_run", "issue_number": issue_number} + + cmd = ["issue", "edit", str(issue_number), "--repo", repo] + if title: + cmd.extend(["--title", sanitize_text(title, max_len=256)]) + if body: + cmd.extend(["--body", body.strip()]) + if add_labels: + for lbl in add_labels: + cmd.extend(["--add-label", lbl]) + if remove_labels: + for lbl in remove_labels: + cmd.extend(["--remove-label", lbl]) + if add_assignees: + for a in add_assignees: + cmd.extend(["--add-assignee", a]) + if remove_assignees: + for a in remove_assignees: + cmd.extend(["--remove-assignee", a]) + + print(f"✏️ Editing issue #{issue_number} in {repo}...") + res = exec_gh(cmd) + if res.returncode != 0: + raise RuntimeError(f"Failed to edit issue #{issue_number}: {res.stderr.strip() or res.stdout.strip()}") + + print(f"✅ Issue #{issue_number} edited successfully.") + return {"status": "success", "issue_number": issue_number} + +def close_issue( + issue_number: int, + comment: Optional[str] = None, + reason: str = "completed", + repo: str = f"{DEFAULT_ORG}/{DEFAULT_REPO}", + dry_run: bool = False, +) -> Dict[str, Any]: + """Closes an existing GitHub issue.""" + if dry_run: + print(f"\n--- DRY-RUN: GitHub Issue #{issue_number} Close ---") + print(f"Repository: {repo}") + print(f"Issue Number: #{issue_number}") + print(f"Reason: {reason}") + if comment: + print(f"Comment: {comment}") + print("---------------------------------------------------") + return {"status": "dry_run", "issue_number": issue_number} + + cmd = ["issue", "close", str(issue_number), "--repo", repo, "--reason", reason] + if comment: + cmd.extend(["--comment", comment.strip()]) + + print(f"🔒 Closing issue #{issue_number} in {repo}...") + res = exec_gh(cmd) + if res.returncode != 0: + raise RuntimeError(f"Failed to close issue #{issue_number}: {res.stderr.strip() or res.stdout.strip()}") + + print(f"✅ Issue #{issue_number} closed successfully.") + return {"status": "success", "issue_number": issue_number} + +def delete_issue( + issue_number: int, + repo: str = f"{DEFAULT_ORG}/{DEFAULT_REPO}", + dry_run: bool = False, +) -> Dict[str, Any]: + """Deletes an existing GitHub issue.""" + if dry_run: + print(f"\n--- DRY-RUN: GitHub Issue #{issue_number} Delete ---") + print(f"Repository: {repo}") + print(f"Issue Number: #{issue_number}") + print("---------------------------------------------------") + return {"status": "dry_run", "issue_number": issue_number} + + cmd = ["issue", "delete", str(issue_number), "--repo", repo, "--yes"] + print(f"🗑️ Deleting issue #{issue_number} in {repo}...") + res = exec_gh(cmd) + if res.returncode != 0: + raise RuntimeError(f"Failed to delete issue #{issue_number}: {res.stderr.strip() or res.stdout.strip()}") + + print(f"✅ Issue #{issue_number} deleted successfully.") + return {"status": "success", "issue_number": issue_number} + def main(): parser = argparse.ArgumentParser(description="Native GitHub CLI Project & Issue Management Automation") subparsers = parser.add_subparsers(dest="command", help="Command to execute") @@ -248,6 +357,32 @@ def main(): p_view.add_argument("--project", type=int, default=DEFAULT_PROJECT_NUMBER, help="Project number") p_view.add_argument("--owner", type=str, default=DEFAULT_ORG, help="Org owner") + # Command: edit-issue + p_edit = subparsers.add_parser("edit-issue", help="Edit existing issue") + p_edit.add_argument("--issue", type=int, required=True, help="Issue number to edit") + p_edit.add_argument("--title", type=str, help="New issue title") + p_edit.add_argument("--body", type=str, help="New issue body") + p_edit.add_argument("--add-label", action="append", help="Label to add (repeatable)") + p_edit.add_argument("--remove-label", action="append", help="Label to remove (repeatable)") + p_edit.add_argument("--add-assignee", action="append", help="Assignee to add (repeatable)") + p_edit.add_argument("--remove-assignee", action="append", help="Assignee to remove (repeatable)") + p_edit.add_argument("--repo", type=str, default=f"{DEFAULT_ORG}/{DEFAULT_REPO}", help="Target repo") + p_edit.add_argument("--dry-run", action="store_true", help="Preview without editing") + + # Command: close-issue + p_close = subparsers.add_parser("close-issue", help="Close existing issue") + p_close.add_argument("--issue", type=int, required=True, help="Issue number to close") + p_close.add_argument("--comment", type=str, help="Closing comment") + p_close.add_argument("--reason", choices=["completed", "not planned"], default="completed", help="Close reason") + p_close.add_argument("--repo", type=str, default=f"{DEFAULT_ORG}/{DEFAULT_REPO}", help="Target repo") + p_close.add_argument("--dry-run", action="store_true", help="Preview without closing") + + # Command: delete-issue + p_del = subparsers.add_parser("delete-issue", help="Delete existing issue") + p_del.add_argument("--issue", type=int, required=True, help="Issue number to delete") + p_del.add_argument("--repo", type=str, default=f"{DEFAULT_ORG}/{DEFAULT_REPO}", help="Target repo") + p_del.add_argument("--dry-run", action="store_true", help="Preview without deleting") + args = parser.parse_args() if not args.command: @@ -268,6 +403,32 @@ def main(): project_owner=args.owner, dry_run=args.dry_run, ) + elif args.command == "edit-issue": + edit_issue( + issue_number=args.issue, + title=args.title, + body=args.body, + add_labels=args.add_label, + remove_labels=args.remove_label, + add_assignees=args.add_assignee, + remove_assignees=args.remove_assignee, + repo=args.repo, + dry_run=args.dry_run, + ) + elif args.command == "close-issue": + close_issue( + issue_number=args.issue, + comment=args.comment, + reason=args.reason, + repo=args.repo, + dry_run=args.dry_run, + ) + elif args.command == "delete-issue": + delete_issue( + issue_number=args.issue, + repo=args.repo, + dry_run=args.dry_run, + ) elif args.command == "link-repo": success = link_repository_to_project( repo_name=args.repo, diff --git a/scripts/run_mobile.sh b/scripts/run_mobile.sh index c9c6519..10d1c56 100755 --- a/scripts/run_mobile.sh +++ b/scripts/run_mobile.sh @@ -21,28 +21,81 @@ RED="\033[0;31m" CYAN="\033[0;36m" NC="\033[0m" -MODE="${1:-}" +RAW_MODE="${1:-}" +TARGET_ENV="${ENV:-${TARGET:-local}}" +FALLBACK_HYBRID="${FALLBACK_HYBRID:-false}" + +case "${RAW_MODE}" in + usb-cloud) + MODE="usb" + TARGET_ENV="cloud" + ;; + emu-cloud) + MODE="emu" + TARGET_ENV="cloud" + ;; + web-cloud) + MODE="web" + TARGET_ENV="cloud" + ;; + cloud) + MODE="usb" + TARGET_ENV="cloud" + ;; + usb-hybrid) + MODE="usb" + FALLBACK_HYBRID=true + ;; + emu-hybrid) + MODE="emu" + FALLBACK_HYBRID=true + ;; + web-hybrid) + MODE="web" + FALLBACK_HYBRID=true + ;; + hybrid) + MODE="usb" + FALLBACK_HYBRID=true + ;; + *) + MODE="${RAW_MODE}" + ;; +esac + PORT="${PORT:-8888}" MCP_PORT="${MCP_PORT:-8889}" usage() { - echo -e "${BOLD}Usage:${NC} $0 <usb|emu|mock|web> [extra_flutter_args...]" + echo -e "${BOLD}Usage:${NC} $0 <usb|emu|mock|web|hybrid|usb-hybrid|emu-hybrid|web-hybrid|usb-cloud|emu-cloud|web-cloud> [extra_flutter_args...]" + echo "" + echo "Modes (DEFAULT: Nembak Local API port ${PORT}):" + echo " usb Jalankan di HP Fisik via USB (DEFAULT: API Lokal via reverse port ${PORT})" + echo " emu Jalankan di Android Emulator (DEFAULT: API Lokal via 10.0.2.2:${PORT})" + echo " web Jalankan di Web Browser Chrome (DEFAULT: API Lokal via 127.0.0.1:${PORT})" + echo " mock Jalankan dengan data simulasi lokal (USE_MOCK_DATA=true, offline)" + echo " hybrid Jalankan mode Hybrid (Remote API dengan fallback otomatis ke Mock)" + echo "" + echo "Hybrid Fallback Modes (Remote API + Auto Mock Fallback on 404/Offline):" + echo " usb-hybrid HP Fisik dengan Hybrid Fallback" + echo " emu-hybrid Android Emulator dengan Hybrid Fallback" + echo " web-hybrid Chrome Web dengan Hybrid Fallback" echo "" - echo "Modes:" - echo " usb Jalankan Eva Mobile di HP Fisik via USB (otomatis adb reverse port ${PORT})" - echo " emu Jalankan Eva Mobile di Android Emulator (route 10.0.2.2:${PORT})" - echo " mock Jalankan Eva Mobile dengan data simulasi lokal (USE_MOCK_DATA=true)" - echo " web Jalankan Eva Mobile di Web Browser (Chrome)" + echo "Cloud Modes (Nembak Production Cloud eva.nexacode.dev):" + echo " usb-cloud Jalankan di HP Fisik nembak ke Cloud (eva.nexacode.dev)" + echo " emu-cloud Jalankan di Android Emulator nembak ke Cloud (eva.nexacode.dev)" + echo " web-cloud Jalankan di Web Browser nembak ke Cloud (eva.nexacode.dev)" echo "" echo "Examples:" - echo " $0 usb # Eva Mobile di HP fisik dengan reverse port ${PORT}" - echo " $0 emu # Eva Mobile di Android Emulator" - echo " $0 mock # Eva Mobile dengan Mock Data" - echo " $0 web # Eva Mobile di Chrome" + echo " $0 usb # HP fisik nembak lokal port ${PORT}" + echo " $0 hybrid # HP fisik dengan mode Hybrid Fallback" + echo " $0 emu-hybrid # Emulator dengan mode Hybrid Fallback" + echo " $0 mock # Eva Mobile dengan Mock Data murni" echo "" echo "Environment Variables:" - echo " DEVICE Tentukan Device ID spesifik (misal: DEVICE=RR8Y900W1RB)" - echo " PORT Port backend FastAPI (default: 8888)" + echo " DEVICE Tentukan Device ID spesifik (misal: DEVICE=RR8Y900W1RB)" + echo " PORT Port backend FastAPI lokal (default: 8888)" + echo " TARGET_ENV Tentukan target server ('local' atau 'cloud')" exit 1 } @@ -89,12 +142,13 @@ fi shift 1 || true check_backend_health() { + echo -e "${CYAN}Memeriksa status backend lokal...${NC}" if command -v curl &>/dev/null; then - if ! curl -s --connect-timeout 2 "http://localhost:${PORT}/health" &>/dev/null; then - echo -e "${YELLOW}⚠️ Perhatian:${NC} Backend EVA di http://localhost:${PORT} belum merespons." - echo -e " Pastikan backend sudah berjalan via ${BOLD}make dev${NC} atau ${BOLD}make dev-docker${NC}.\n" + if ! curl -s --connect-timeout 2 "http://127.0.0.1:${PORT}/health" &>/dev/null; then + echo -e "${YELLOW}⚠️ Perhatian:${NC} Backend EVA di http://127.0.0.1:${PORT} belum merespons." + echo -e " Pastikan backend sudah berjalan via ${BOLD}make dev${NC} atau ${BOLD}make dev-d${NC}.\n" else - echo -e "${GREEN}✓ Backend EVA aktif di port ${PORT}.${NC}" + echo -e "${GREEN}✓ Backend EVA aktif di port ${PORT}.${NC}\n" fi fi } @@ -103,11 +157,17 @@ check_backend_health() { # MODE: USB (Physical Device) # ============================================================================== if [ "${MODE}" = "usb" ]; then - echo -e "\n${BOLD}${CYAN}================================================================================${NC}" - echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — HP FISIK (USB DEBUGGING) ${NC}" - echo -e "${BOLD}${CYAN}================================================================================${NC}\n" - - check_backend_health + if [ "${TARGET_ENV}" = "cloud" ]; then + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — HP FISIK (CLOUD: eva.nexacode.dev) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + echo -e "${GREEN}🌐 Menghubungkan ke Server Production Cloud...${NC}\n" + else + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — HP FISIK (LOCAL API: 127.0.0.1:${PORT}) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + check_backend_health + fi # Check for unauthorized devices if "${ADB_BIN}" devices | grep -q "unauthorized"; then @@ -139,30 +199,59 @@ if [ "${MODE}" = "usb" ]; then fi echo -e "${CYAN}Target Device:${NC} ${BOLD}${TARGET_DEVICE}${NC} (Eva Mobile)" - echo -e "${CYAN}Konfigurasi Port Forwarding:${NC} Menghubungkan port ${PORT} & ${MCP_PORT} HP -> PC..." - # Perform adb reverse for API & MCP - if "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${PORT}" "tcp:${PORT}"; then - echo -e "${GREEN}✓ Berhasil reverse port API:${NC} tcp:${PORT} -> tcp:${PORT} pada ${TARGET_DEVICE}" - else - echo -e "${RED}[ERROR] Gagal melakukan adb reverse port ${PORT}.${NC}" - exit 1 + if [ "${TARGET_ENV}" != "cloud" ]; then + echo -e "${CYAN}Konfigurasi Port Forwarding:${NC} Menghubungkan port ${PORT} & ${MCP_PORT} HP -> PC..." + # Perform adb reverse for API & MCP + if "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${PORT}" "tcp:${PORT}"; then + echo -e "${GREEN}✓ Berhasil reverse port API:${NC} tcp:${PORT} -> tcp:${PORT} pada ${TARGET_DEVICE}" + else + echo -e "${RED}[ERROR] Gagal melakukan adb reverse port ${PORT}.${NC}" + exit 1 + fi + "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${MCP_PORT}" "tcp:${MCP_PORT}" 2>/dev/null || true + fi + + if [ "${FALLBACK_HYBRID}" = "true" ]; then + echo -e "${GREEN}✓ Hybrid Fallback diaktifkan (Auto-mock pada 404 / server offline).${NC}\n" fi - "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${MCP_PORT}" "tcp:${MCP_PORT}" 2>/dev/null || true - echo -e "\n${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di ${TARGET_DEVICE}...${NC}\n" + echo -e "${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di ${TARGET_DEVICE}...${NC}" + echo -e "${CYAN}Mengompilasi dan memulai aplikasi (Gradle assembleDebug), mohon tunggu...${NC}\n" cd "${APP_DIR}" - exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" --dart-define=USE_LOCALHOST=true "$@" + if [ "${TARGET_ENV}" = "cloud" ]; then + exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" \ + --dart-define=USE_LOCAL_API=false \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + else + exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" \ + --dart-define=USE_LOCAL_API=true \ + --dart-define=LOCAL=true \ + --dart-define=USE_LOCALHOST=true \ + --dart-define=LOCAL_HOST="127.0.0.1:${PORT}" \ + --dart-define=API_BASE_URL="http://127.0.0.1:${PORT}/api/v1" \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + fi # ============================================================================== # MODE: EMU (Android Emulator) # ============================================================================== elif [ "${MODE}" = "emu" ]; then - echo -e "\n${BOLD}${CYAN}================================================================================${NC}" - echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — ANDROID EMULATOR ${NC}" - echo -e "${BOLD}${CYAN}================================================================================${NC}\n" - - check_backend_health + if [ "${TARGET_ENV}" = "cloud" ]; then + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — ANDROID EMULATOR (CLOUD: eva.nexacode.dev) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + echo -e "${GREEN}🌐 Menghubungkan ke Server Production Cloud...${NC}\n" + else + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — ANDROID EMULATOR (LOCAL API: 10.0.2.2:${PORT}) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + check_backend_health + fi # Detect running emulators mapfile -t EMU_DEVICES < <("${ADB_BIN}" devices | awk '$2=="device" && $1~/^emulator-/ {print $1}') @@ -187,13 +276,37 @@ elif [ "${MODE}" = "emu" ]; then echo -e "${CYAN}Target Emulator:${NC} ${BOLD}${TARGET_DEVICE}${NC} (Eva Mobile)" - # Also reverse port on emulator as safety measure - "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${PORT}" "tcp:${PORT}" 2>/dev/null || true - echo -e "${GREEN}✓ Reverse port & bridge alias 10.0.2.2:${PORT} siap.${NC}" + if [ "${TARGET_ENV}" != "cloud" ]; then + # Also reverse port on emulator as safety measure + "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${PORT}" "tcp:${PORT}" 2>/dev/null || true + echo -e "${GREEN}✓ Reverse port & bridge alias 10.0.2.2:${PORT} siap.${NC}" + fi - echo -e "\n${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di emulator ${TARGET_DEVICE}...${NC}\n" + if [ "${FALLBACK_HYBRID}" = "true" ]; then + echo -e "${GREEN}✓ Hybrid Fallback diaktifkan (Auto-mock pada 404 / server offline).${NC}\n" + fi + + echo -e "${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di emulator ${TARGET_DEVICE}...${NC}" + echo -e "${CYAN}Mengompilasi dan memulai aplikasi (Gradle assembleDebug), mohon tunggu...${NC}\n" cd "${APP_DIR}" - exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" --dart-define=API_BASE_URL="http://10.0.2.2:${PORT}/api/v1" "$@" + if [ "${TARGET_ENV}" = "cloud" ]; then + exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" \ + --dart-define=USE_LOCAL_API=false \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + else + exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" \ + --dart-define=USE_LOCAL_API=true \ + --dart-define=LOCAL=true \ + --dart-define=IS_EMULATOR=true \ + --dart-define=EMULATOR=true \ + --dart-define=LOCAL_HOST="10.0.2.2:${PORT}" \ + --dart-define=API_BASE_URL="http://10.0.2.2:${PORT}/api/v1" \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + fi # ============================================================================== # MODE: MOCK (Local Mock Data Mode) @@ -212,14 +325,40 @@ elif [ "${MODE}" = "mock" ]; then # MODE: WEB (Chrome Browser Simulator) # ============================================================================== elif [ "${MODE}" = "web" ]; then - echo -e "\n${BOLD}${CYAN}================================================================================${NC}" - echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — WEB SIMULATOR (CHROME) ${NC}" - echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + if [ "${TARGET_ENV}" = "cloud" ]; then + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — WEB SIMULATOR (CLOUD: eva.nexacode.dev) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + echo -e "${GREEN}🌐 Menghubungkan ke Server Production Cloud...${NC}\n" + else + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — WEB SIMULATOR (LOCAL API: 127.0.0.1:${PORT}) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + check_backend_health + fi - check_backend_health - echo -e "\n${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di Chrome...${NC}\n" + if [ "${FALLBACK_HYBRID}" = "true" ]; then + echo -e "${GREEN}✓ Hybrid Fallback diaktifkan (Auto-mock pada 404 / server offline).${NC}\n" + fi + + echo -e "${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di Chrome...${NC}\n" cd "${APP_DIR}" - exec "${FLUTTER_BIN}" run -d chrome "$@" + if [ "${TARGET_ENV}" = "cloud" ]; then + exec "${FLUTTER_BIN}" run -d chrome \ + --dart-define=USE_LOCAL_API=false \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + else + exec "${FLUTTER_BIN}" run -d chrome \ + --dart-define=USE_LOCAL_API=true \ + --dart-define=LOCAL=true \ + --dart-define=LOCAL_HOST="127.0.0.1:${PORT}" \ + --dart-define=API_BASE_URL="http://127.0.0.1:${PORT}/api/v1" \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + fi else usage diff --git a/tests/agentic/test_gh_project.py b/tests/agentic/test_gh_project.py index f4c3c40..06189cb 100644 --- a/tests/agentic/test_gh_project.py +++ b/tests/agentic/test_gh_project.py @@ -8,6 +8,9 @@ from unittest.mock import patch, MagicMock from scripts.agentic.gh_project import ( create_issue, + edit_issue, + close_issue, + delete_issue, link_repository_to_project, get_gh_env, DEFAULT_ORG, @@ -80,5 +83,92 @@ def test_create_issue_success(self, mock_exec): self.assertEqual(result.get("issue_number"), 100) self.assertEqual(result.get("item_id"), "PVTI_lADOAA") + def test_edit_issue_dry_run(self): + result = edit_issue( + issue_number=100, + title="Updated Title", + body="Updated Body", + add_labels=["bug"], + dry_run=True, + ) + self.assertEqual(result.get("status"), "dry_run") + self.assertEqual(result.get("issue_number"), 100) + + @patch("scripts.agentic.gh_project.exec_gh") + def test_edit_issue_success(self, mock_exec): + mock_res = MagicMock() + mock_res.returncode = 0 + mock_res.stdout = "" + mock_exec.return_value = mock_res + + result = edit_issue( + issue_number=100, + title="Updated Title", + add_labels=["bug"], + dry_run=False, + ) + self.assertEqual(result.get("status"), "success") + self.assertEqual(result.get("issue_number"), 100) + mock_exec.assert_called_once() + args = mock_exec.call_args[0][0] + self.assertIn("edit", args) + self.assertIn("100", args) + self.assertIn("--add-label", args) + self.assertIn("bug", args) + + def test_close_issue_dry_run(self): + result = close_issue( + issue_number=100, + comment="Completed in PR #12", + dry_run=True, + ) + self.assertEqual(result.get("status"), "dry_run") + self.assertEqual(result.get("issue_number"), 100) + + @patch("scripts.agentic.gh_project.exec_gh") + def test_close_issue_success(self, mock_exec): + mock_res = MagicMock() + mock_res.returncode = 0 + mock_res.stdout = "" + mock_exec.return_value = mock_res + + result = close_issue( + issue_number=100, + comment="Done!", + dry_run=False, + ) + self.assertEqual(result.get("status"), "success") + mock_exec.assert_called_once() + args = mock_exec.call_args[0][0] + self.assertIn("close", args) + self.assertIn("100", args) + self.assertIn("--comment", args) + + def test_delete_issue_dry_run(self): + result = delete_issue( + issue_number=100, + dry_run=True, + ) + self.assertEqual(result.get("status"), "dry_run") + self.assertEqual(result.get("issue_number"), 100) + + @patch("scripts.agentic.gh_project.exec_gh") + def test_delete_issue_success(self, mock_exec): + mock_res = MagicMock() + mock_res.returncode = 0 + mock_res.stdout = "" + mock_exec.return_value = mock_res + + result = delete_issue( + issue_number=100, + dry_run=False, + ) + self.assertEqual(result.get("status"), "success") + mock_exec.assert_called_once() + args = mock_exec.call_args[0][0] + self.assertIn("delete", args) + self.assertIn("100", args) + self.assertIn("--yes", args) + if __name__ == "__main__": unittest.main()