diff --git a/.dockerignore b/.dockerignore index 76fa2f3..1a25281 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,4 +18,5 @@ build/ # Exclude heavy non-backend folders for super lean docker builds docs/ tests/ -apps/luna_mobile/ +apps/eva_mobile/ +apps/eva_counselor_mobile/ diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 2aeab0b..3c2320b 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -36,7 +36,7 @@ jobs: if: ${{ github.event_name == 'push' || github.event.inputs.target == 'all' || github.event.inputs.target == 'android' || github.event.inputs.target == null }} defaults: run: - working-directory: apps/luna_mobile + working-directory: apps/eva_mobile steps: - name: Checkout repository @@ -75,8 +75,8 @@ jobs: - name: Upload Android APK uses: actions/upload-artifact@v4 with: - name: luna-mobile-android-${{ github.event.inputs.build_type || 'release' }}-apk - path: apps/luna_mobile/${{ env.APK_PATH }} + name: eva-mobile-android-${{ github.event.inputs.build_type || 'release' }}-apk + path: apps/eva_mobile/${{ env.APK_PATH }} if-no-files-found: error retention-days: 14 @@ -86,7 +86,7 @@ jobs: if: ${{ github.event_name == 'push' || github.event.inputs.target == 'all' || github.event.inputs.target == 'ios' || github.event.inputs.target == null }} defaults: run: - working-directory: apps/luna_mobile + working-directory: apps/eva_mobile steps: - name: Checkout repository @@ -115,7 +115,7 @@ jobs: fi BUILD_DIR="build/ios/iphoneos" - IPA_NAME="LunaMobile-iPhone-${BUILD_TYPE}-unsigned.ipa" + IPA_NAME="EvaMobile-iPhone-${BUILD_TYPE}-unsigned.ipa" echo "Packaging Runner.app into standard iPhone installer (.ipa)..." cd "$BUILD_DIR" @@ -131,7 +131,7 @@ jobs: - name: Upload iPhone IPA uses: actions/upload-artifact@v4 with: - name: luna-mobile-iphone-${{ github.event.inputs.build_type || 'release' }}-ipa - path: apps/luna_mobile/${{ env.IPA_PATH }} + name: eva-mobile-iphone-${{ github.event.inputs.build_type || 'release' }}-ipa + path: apps/eva_mobile/${{ env.IPA_PATH }} if-no-files-found: error retention-days: 14 diff --git a/.gitignore b/.gitignore index 1eb150f..fdbc238 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ downloads/ eggs/ .eggs/ lib/ -!apps/luna_mobile/lib/ +!apps/eva_mobile/lib/ +!apps/eva_counselor_mobile/lib/ lib64/ parts/ sdist/ @@ -47,12 +48,20 @@ htmlcov/ .dockerdata/ # Flutter / Mobile -apps/luna_mobile/.dart_tool/ -apps/luna_mobile/.fvm/ -apps/luna_mobile/.idea/ -apps/luna_mobile/.pub-cache/ -apps/luna_mobile/.pub/ -apps/luna_mobile/build/ +apps/eva_mobile/.dart_tool/ +apps/eva_mobile/.fvm/ +apps/eva_mobile/.idea/ +apps/eva_mobile/.pub-cache/ +apps/eva_mobile/.pub/ +apps/eva_mobile/build/ +apps/eva_counselor_mobile/.dart_tool/ +apps/eva_counselor_mobile/.fvm/ +apps/eva_counselor_mobile/.idea/ +apps/eva_counselor_mobile/.pub-cache/ +apps/eva_counselor_mobile/.pub/ +apps/eva_counselor_mobile/build/ +.flutter-plugins +.flutter-plugins-dependencies # Agents logs .agents/logs/ @@ -65,3 +74,6 @@ apps/luna_mobile/build/ # Test audio outputs & pitch exploration tests/output_*.mp3 tests/samples_pitch/ + +# User reference screenshots +immg/ diff --git a/Makefile b/Makefile index 898c06c..32f3016 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,8 @@ help: @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 "" @echo " Testing & Code Quality:" @echo " make test - Jalankan seluruh test suite (Backend via Docker & Mobile)" @@ -134,6 +136,15 @@ mobile-mock: mobile-web: @./scripts/run_mobile.sh web $(ARGS) +counselor-mock: + @APP=eva_counselor_mobile ./scripts/run_mobile.sh mock $(ARGS) + +counselor-web: + @APP=eva_counselor_mobile ./scripts/run_mobile.sh web $(ARGS) + +test-counselor: + cd apps/eva_counselor_mobile && flutter test + # ------------------------------------------------------------------------------ # Testing & Code Quality # ------------------------------------------------------------------------------ diff --git a/README.md b/README.md index f5b5ecc..3f1114d 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ docker compose up --build | **Redis** | `6380` | `6379` | `localhost:6380` | | **Qdrant Vector DB** | `6333` | `6333` | `http://localhost:6333` | -> â„šī¸ *Detail panduan Docker dapat dibaca di [scripts/DOCKER_SETUP.md](file:///home/mashupsoat/Project/luna_ai/scripts/DOCKER_SETUP.md).* +> â„šī¸ *Detail panduan Docker dapat dibaca di [scripts/DOCKER_SETUP.md](file:///home/mashupsoat/Project/eva-ecosystem/scripts/DOCKER_SETUP.md).* --- @@ -192,7 +192,7 @@ Aplikasi Flutter `eva_mobile` dapat dijalankan di **Perangkat Fisik (HP Android #### **2. Menjalankan Aplikasi Flutter (Mode Lokal vs Production):** -Secara default, jika dijalankan dengan `flutter run`, aplikasi akan terhubung ke server production (`https://luna.nexacode.dev`). Untuk menghubungkannya ke **server backend lokal laptop**: +Secara default, jika dijalankan dengan `flutter run`, aplikasi akan terhubung ke server production (`https://eva.nexacode.dev`). Untuk menghubungkannya ke **server backend lokal laptop**: ##### **A. Perangkat Fisik Android (Physical Device via USB) & Desktop / Web:** 1. Lakukan reverse port ADB agar port di dalam HP meneruskan request ke laptop via kabel USB: @@ -237,7 +237,7 @@ Tambahkan konfigurasi berikut ke `.vscode/launch.json` di root workspace agar da "version": "0.2.0", "configurations": [ { - "name": "Luna Mobile (HP Fisik USB - Local)", + "name": "Eva Mobile (HP Fisik USB - Local)", "request": "launch", "type": "dart", "program": "apps/eva_mobile/lib/main.dart", @@ -246,7 +246,7 @@ Tambahkan konfigurasi berikut ke `.vscode/launch.json` di root workspace agar da ] }, { - "name": "Luna Mobile (Android Emulator - Local)", + "name": "Eva Mobile (Android Emulator - Local)", "request": "launch", "type": "dart", "program": "apps/eva_mobile/lib/main.dart", @@ -256,7 +256,7 @@ Tambahkan konfigurasi berikut ke `.vscode/launch.json` di root workspace agar da ] }, { - "name": "Luna Mobile (Production Cloud)", + "name": "Eva Mobile (Production Cloud)", "request": "launch", "type": "dart", "program": "apps/eva_mobile/lib/main.dart" @@ -326,7 +326,7 @@ pytest packages/ai/tests ```bash python3 tests/test_emotion/test_emotion.py ``` -*Hasil pengujian emotion recognition akan otomatis ditulis ke [tests/test_emotion/poc_result.md](file:///e:/nexaCode/luna_ai/tests/test_emotion/poc_result.md).* +*Hasil pengujian emotion recognition akan otomatis ditulis ke [tests/test_emotion/poc_result.md](file:///e:/nexaCode/eva-ecosystem/tests/test_emotion/poc_result.md).* --- @@ -334,7 +334,7 @@ python3 tests/test_emotion/test_emotion.py ```mermaid graph TD - Client["📱 Luna Mobile (Flutter)"] -->|REST / WebSocket :8888| API["⚡ FastAPI Backend"] + Client["📱 Eva Mobile (Flutter)"] -->|REST / WebSocket :8888| API["⚡ FastAPI Backend"] LLM["🤖 LLM Agent / Client"] -->|MCP Protocol :8889| MCP["đŸ› ī¸ FastMCP Server"] API -->|Read/Write| DB[(🐘 PostgreSQL :5433)] diff --git a/apps/backend/api/app/api/routes/conversations.py b/apps/backend/api/app/api/routes/conversations.py index 51ab33d..45be255 100644 --- a/apps/backend/api/app/api/routes/conversations.py +++ b/apps/backend/api/app/api/routes/conversations.py @@ -200,7 +200,7 @@ async def get_today_conversations( 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." + 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: @@ -242,7 +242,7 @@ async def get_today_conversations( items.append({ "id": str(c.id), - "title": c.title or "Sesi Panggilan Suara LUNA", + "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), @@ -297,7 +297,7 @@ async def get_today_conversation_messages( 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 LUNA").replace("$skeleton", "").strip() + 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" @@ -466,7 +466,7 @@ async def cached_stream(): 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." + 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" @@ -485,10 +485,10 @@ async def empty_stream(): system_prompt = LLMMessage( role="system", content=( - "Anda adalah konselor psikologis profesional LUNA AI. " + "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 LUNA.\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" @@ -511,11 +511,11 @@ async def generate_stream(): 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, " + "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. " - "LUNA memberikan tanggapan yang menenangkan serta ruang aman untuk meredakan ketegangan." + "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" diff --git a/apps/backend/api/app/api/routes/diaries.py b/apps/backend/api/app/api/routes/diaries.py index c71a32d..2136063 100644 --- a/apps/backend/api/app/api/routes/diaries.py +++ b/apps/backend/api/app/api/routes/diaries.py @@ -70,7 +70,7 @@ 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 LUNA."] + 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)) @@ -97,19 +97,19 @@ def _format_diary_entry(d: DiaryEntry | None) -> dict[str, Any]: return { "id": str(d.id), - "title": d.title or "Refleksi Harian LUNA", + "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 LUNA.", + "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 LUNA mendeteksi akumulasi indikasi krisis emosional tinggi dan stres berat pada percakapan hari ini. Protokol keselamatan aktif untuk rujukan darurat 119 ext 8.", + "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, diff --git a/apps/backend/api/app/services/analytics_service.py b/apps/backend/api/app/services/analytics_service.py index ae46432..33bcab2 100644 --- a/apps/backend/api/app/services/analytics_service.py +++ b/apps/backend/api/app/services/analytics_service.py @@ -319,7 +319,7 @@ async def _get_today_monitoring(user_id: uuid.UUID, db: AsyncSession) -> dict[st depression_pct = float(scores.get("depression", 0.0)) else: level = 0 - summary = "LUNA belum mencatat percakapanmu hari ini. Ceritakan harimu untuk mulai memantau ritem emosionalmu." + summary = "EVA belum mencatat percakapanmu hari ini. Ceritakan harimu untuk mulai memantau ritem emosionalmu." desc = "Belum ada interaksi percakapan atau catatan jurnal untuk hari ini. Mulai sesi curhat untuk menganalisis pusat emosionalmu." stress_pct = 0.0 anxiety_pct = 0.0 @@ -432,7 +432,7 @@ async def _get_week_monitoring(user_id: uuid.UUID, db: AsyncSession) -> dict[str stress_avg = 0.0 anx_avg = 0.0 dep_avg = 0.0 - summary = "Belum ada riwayat percakapan atau jurnal emosi yang tercatat untuk minggu ini. Mulai sesi curhat bersama LUNA untuk melihat dinamika emosimu." + summary = "Belum ada riwayat percakapan atau jurnal emosi yang tercatat untuk minggu ini. Mulai sesi curhat bersama EVA untuk melihat dinamika emosimu." desc = "Belum ada data emosional tercatat untuk minggu ini." risks = [ @@ -529,7 +529,7 @@ async def _get_month_monitoring(user_id: uuid.UUID, db: AsyncSession) -> dict[st stress_avg = 0.0 anx_avg = 0.0 dep_avg = 0.0 - summary = f"Belum ada data emosional yang tercatat di bulan {month_name}. Ceritakan harimu secara rutin agar LUNA dapat memetakan tren kesehatan mentalmu." + summary = f"Belum ada data emosional yang tercatat di bulan {month_name}. Ceritakan harimu secara rutin agar EVA dapat memetakan tren kesehatan mentalmu." desc = f"Belum ada data emosional tercatat untuk bulan {month_name}." risks = [ diff --git a/apps/backend/api/app/services/call_persistence_service.py b/apps/backend/api/app/services/call_persistence_service.py index 7443466..70892b6 100644 --- a/apps/backend/api/app/services/call_persistence_service.py +++ b/apps/backend/api/app/services/call_persistence_service.py @@ -109,7 +109,7 @@ async def load_conversation_history(session: Any) -> None: now_str = get_wib_now().strftime("%d %b %Y %H:%M") new_conv = Conversation( user_id=user.id, - title=f"Panggilan Suara LUNA {now_str}", + title=f"Panggilan Suara EVA {now_str}", ) db.add(new_conv) await db.commit() diff --git a/apps/backend/api/app/services/diary_generator.py b/apps/backend/api/app/services/diary_generator.py index d8a3909..f04ca53 100644 --- a/apps/backend/api/app/services/diary_generator.py +++ b/apps/backend/api/app/services/diary_generator.py @@ -60,7 +60,7 @@ async def generate_today_diary(user_id: uuid.UUID, db: AsyncSession) -> DiaryEnt system_prompt = LLMMessage( role="system", content=( - "Anda adalah sistem analis kesehatan mental & konseling psikologi LUNA AI. " + "Anda adalah sistem analis kesehatan mental & konseling psikologi EVA AI. " "Tugas Anda adalah menganalisis seluruh transkrip percakapan harian pengguna hari ini, " "lalu menghasilkan output JSON VALID HANYA TANPA MARKDOWN (tanpa ```json atau teks tambahan lainnya).\n\n" "Format JSON yang HARUS dihasilkan secara presisi:\n" @@ -126,8 +126,8 @@ async def _save_parsed_diary( "depression": 0.15, } ai_insight = data.get("ai_insight") or "Kondisi emosional pengguna terpantau stabil." - emotional_reflection = data.get("emotional_reflection") or "Pengguna merasa lebih tenang setelah berdialog bersama LUNA." - important_events = data.get("important_events") or [f"[{get_wib_now().strftime('%H:%M')}] Sesi Percakapan LUNA"] + emotional_reflection = data.get("emotional_reflection") or "Pengguna merasa lebih tenang setelah berdialog bersama EVA." + important_events = data.get("important_events") or [f"[{get_wib_now().strftime('%H:%M')}] Sesi Percakapan EVA"] # Check existing diary entry for today query = select(DiaryEntry).where(DiaryEntry.user_id == user_id, DiaryEntry.entry_date == entry_date).order_by(DiaryEntry.created_at.desc()) @@ -171,7 +171,7 @@ async def _upsert_default_diary(user_id: uuid.UUID, entry_date: date, db: AsyncS default_data = { "title": f"Refleksi Harian - {entry_date.strftime('%d %b %Y')}", "summary": "Sesi percakapan hari ini berjalan dengan baik dan memberikan ketenangan emosional.", - "content": "Pengguna telah berinteraksi dengan LUNA AI untuk merefleksikan perasaan dan aktivitas hari ini.", + "content": "Pengguna telah berinteraksi dengan EVA AI untuk merefleksikan perasaan dan aktivitas hari ini.", "mood_tag": "Netral & Tenang 😌", "mood_emoji": "😌", "emotional_level": 4, @@ -181,7 +181,7 @@ async def _upsert_default_diary(user_id: uuid.UUID, entry_date: date, db: AsyncS "depression": 0.15, }, "ai_insight": "Pengguna merasa didengarkan dan mulai merasa lebih rileks selama percakapan.", - "emotional_reflection": "Refleksi emosi menunjukkan respon positif terhadap konseling LUNA.", - "important_events": [f"[{get_wib_now().strftime('%H:%M')}] Sesi Panggilan Suara & Chat LUNA"], + "emotional_reflection": "Refleksi emosi menunjukkan respon positif terhadap konseling EVA.", + "important_events": [f"[{get_wib_now().strftime('%H:%M')}] Sesi Panggilan Suara & Chat EVA"], } return await DiaryGeneratorService._save_parsed_diary(user_id, entry_date, default_data, db) diff --git a/apps/backend/api/app/services/emotion_analyzer.py b/apps/backend/api/app/services/emotion_analyzer.py index b2447ea..ba6b653 100644 --- a/apps/backend/api/app/services/emotion_analyzer.py +++ b/apps/backend/api/app/services/emotion_analyzer.py @@ -25,8 +25,8 @@ async def analyze_transcript(conversation_history: list[LLMMessage]) -> dict[str system_prompt = LLMMessage( role="system", content=( - "Anda adalah sistem analisis emosi psikologi LUNA AI. " - "Tugas Anda adalah menganalisis transkrip percakapan konseling suara antara Pengguna dan LUNA AI, " + "Anda adalah sistem analisis emosi psikologi EVA AI. " + "Tugas Anda adalah menganalisis transkrip percakapan konseling suara antara Pengguna dan EVA AI, " "lalu menghasilkan output berupa JSON VALID HANYA TANPA MARKDOWN (tanpa ```json atau teks lain).\n\n" "Format JSON yang HARUS dihasilkan:\n" "{\n" @@ -43,7 +43,7 @@ async def analyze_transcript(conversation_history: list[LLMMessage]) -> dict[str ' {"label": "", "emoji": "😟", "percent": , "color": "#FF8B94"}\n' " ],\n" ' "important_events": ["", ""],\n' - ' "diary_summary": ""\n' + ' "diary_summary": ""\n' "}\n\n" "ATURAN WAJIB:\n" "- Nilai 'percent' pada 'emotions_breakdown' HARUS dihitung secara dinamis dan realistis sesuai sentimen transkrip nyata, TIDAK BOLEH bernilai statis.\n" @@ -83,7 +83,7 @@ async def analyze_transcript(conversation_history: list[LLMMessage]) -> dict[str @staticmethod def _default_fallback_analysis() -> dict[str, Any]: return { - "session_title": "Sesi Curhat Bersama LUNA", + "session_title": "Sesi Curhat Bersama EVA", "dominant_emotion": "Tenang & Nyaman đŸŒŋ", "calm_score": "85%", "stress_level": "Rendah", @@ -95,6 +95,6 @@ def _default_fallback_analysis() -> dict[str, Any]: {"label": "Bahagia & Lega", "emoji": "😃", "percent": 0.25, "color": "#FFE6A7"}, {"label": "Tingkat Stres", "emoji": "😟", "percent": 0.10, "color": "#FF8B94"}, ], - "important_events": ["Panggilan suara konseling LUNA AI"], - "diary_summary": "Sesi percakapan curhat bersama LUNA memberikan ruang aman untuk mengekspresikan emosi serta menemukan ketenangan batin.", + "important_events": ["Panggilan suara konseling EVA AI"], + "diary_summary": "Sesi percakapan curhat bersama EVA memberikan ruang aman untuk mengekspresikan emosi serta menemukan ketenangan batin.", } diff --git a/apps/eva_counselor_mobile/AGENTS.md b/apps/eva_counselor_mobile/AGENTS.md new file mode 100644 index 0000000..47a4643 --- /dev/null +++ b/apps/eva_counselor_mobile/AGENTS.md @@ -0,0 +1,31 @@ +# Eva Counselor Mobile (Flutter) - Sub-Workspace Rules + +> Scope: `apps/eva_counselor_mobile/**` +> Target User Persona: Licensed Clinical Psychologist / Mental Health Counselor + +## 1. Clean Architecture & Strict Layer Separation +- **Domain (`lib/features//domain`)**: + - Contains pure Dart entities and abstract repository contracts (e.g. `abstract class IPatientRepository`). + - ZERO Flutter widget dependencies or network clients in this layer. +- **Data (`lib/features//data`)**: + - DTOs / Models with explicit `fromJson` and `toJson` schemas representing the exact backend API contract. + - Dual data source implementations are mandatory: `MockDataSource` and `RemoteDataSource`. + - Both data sources implement an abstract `DataSource` contract. + - Repository implementation (`RepositoryImpl`) coordinates data sources and maps models to domain entities. +- **Presentation (`lib/features//presentation`)**: + - Riverpod providers bind data sources and repositories. + - **STRICT PROHIBITION**: Widgets and screens must **NEVER** inspect `AppConfig.useMockData` or instantiate data sources directly. + +## 2. Mock vs Production Switching +- Switching between local mock data and real backend API is governed strictly at the provider layer via `AppConfig.useMockData`. +- Support `--dart-define=USE_MOCK_DATA=true` or `--dart-define=USE_MOCK_DATA=false`. +- Real backend API points to `AppConfig.apiBaseUrl` (default: `https://eva.nexacode.dev/api/v1/counselor`). + +## 3. Design System & Tokens +- Reuses the shared palette: + - Primary: `0xFF5358CB` (Periwinkle) + - Secondary: `0xFFE2DAFF` (Soft Lavender) + - Tertiary: `0xFFA7E6FF` (Sky Blue) + - Background: `0xFFF8F9FE` (Cool Tint Off-White) + - Surface: `0xFFFFFFFF` + - Font: `GoogleFonts.inter()` diff --git a/apps/eva_counselor_mobile/analysis_options.yaml b/apps/eva_counselor_mobile/analysis_options.yaml new file mode 100644 index 0000000..722238d --- /dev/null +++ b/apps/eva_counselor_mobile/analysis_options.yaml @@ -0,0 +1,12 @@ +analyzer: + exclude: + - build/** + - android/** + - web/** +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: true + prefer_const_literals_to_create_immutables: true + avoid_print: false diff --git a/apps/eva_counselor_mobile/android/.gitignore b/apps/eva_counselor_mobile/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/apps/eva_counselor_mobile/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/apps/eva_counselor_mobile/android/app/build.gradle.kts b/apps/eva_counselor_mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..ca67767 --- /dev/null +++ b/apps/eva_counselor_mobile/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.eva_mobile" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.eva_counselor_mobile" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/apps/eva_counselor_mobile/android/app/src/debug/AndroidManifest.xml b/apps/eva_counselor_mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/eva_counselor_mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/eva_counselor_mobile/android/app/src/main/AndroidManifest.xml b/apps/eva_counselor_mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d0b530f --- /dev/null +++ b/apps/eva_counselor_mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/eva_counselor_mobile/android/app/src/main/kotlin/com/example/eva_mobile/MainActivity.kt b/apps/eva_counselor_mobile/android/app/src/main/kotlin/com/example/eva_mobile/MainActivity.kt new file mode 100644 index 0000000..906b81b --- /dev/null +++ b/apps/eva_counselor_mobile/android/app/src/main/kotlin/com/example/eva_mobile/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.eva_mobile + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/eva_counselor_mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/eva_counselor_mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/eva_counselor_mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/eva_counselor_mobile/android/app/src/main/res/drawable/launch_background.xml b/apps/eva_counselor_mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/eva_counselor_mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..28c5c75 Binary files /dev/null and b/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d67f96a Binary files /dev/null and b/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..07ed8d9 Binary files /dev/null and b/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..1b26258 Binary files /dev/null and b/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..a94bb5f Binary files /dev/null and b/apps/eva_counselor_mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/eva_counselor_mobile/android/app/src/main/res/values-night/styles.xml b/apps/eva_counselor_mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/eva_counselor_mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/eva_counselor_mobile/android/app/src/main/res/values/styles.xml b/apps/eva_counselor_mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/eva_counselor_mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/eva_counselor_mobile/android/app/src/profile/AndroidManifest.xml b/apps/eva_counselor_mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/eva_counselor_mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/eva_counselor_mobile/android/build.gradle.kts b/apps/eva_counselor_mobile/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/eva_counselor_mobile/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/apps/eva_counselor_mobile/android/gradle.properties b/apps/eva_counselor_mobile/android/gradle.properties new file mode 100644 index 0000000..7614974 --- /dev/null +++ b/apps/eva_counselor_mobile/android/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# org.gradle.java.home is machine/OS-specific. Set JAVA_HOME in your environment or user ~/.gradle/gradle.properties instead of hardcoding absolute paths here. +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/apps/eva_counselor_mobile/android/gradle/wrapper/gradle-wrapper.properties b/apps/eva_counselor_mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/apps/eva_counselor_mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/apps/eva_counselor_mobile/android/settings.gradle.kts b/apps/eva_counselor_mobile/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/apps/eva_counselor_mobile/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/apps/eva_counselor_mobile/assets/images/eva_logo.png b/apps/eva_counselor_mobile/assets/images/eva_logo.png new file mode 100644 index 0000000..6aa3791 Binary files /dev/null and b/apps/eva_counselor_mobile/assets/images/eva_logo.png differ diff --git a/apps/eva_counselor_mobile/assets/images/eva_logo_square.png b/apps/eva_counselor_mobile/assets/images/eva_logo_square.png new file mode 100644 index 0000000..6aa3791 Binary files /dev/null and b/apps/eva_counselor_mobile/assets/images/eva_logo_square.png differ diff --git a/apps/eva_counselor_mobile/assets/images/luna_logo.png b/apps/eva_counselor_mobile/assets/images/luna_logo.png new file mode 100644 index 0000000..6aa3791 Binary files /dev/null and b/apps/eva_counselor_mobile/assets/images/luna_logo.png differ diff --git a/apps/eva_counselor_mobile/assets/images/luna_logo_square.png b/apps/eva_counselor_mobile/assets/images/luna_logo_square.png new file mode 100644 index 0000000..6aa3791 Binary files /dev/null and b/apps/eva_counselor_mobile/assets/images/luna_logo_square.png differ diff --git a/apps/eva_counselor_mobile/lib/core/config/app_config.dart b/apps/eva_counselor_mobile/lib/core/config/app_config.dart new file mode 100644 index 0000000..7e4cbe8 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/config/app_config.dart @@ -0,0 +1,54 @@ +class AppConfig { + /// Toggle between Mock Data (offline/local development) and Live Backend API. + /// Can be overridden at build time via: + /// --dart-define=USE_MOCK_DATA=true / false + static const bool useMockData = bool.fromEnvironment( + 'USE_MOCK_DATA', + defaultValue: true, + ); + + /// Toggle local API endpoint (127.0.0.1:8888 or 10.0.2.2:8888 for Android emulator) + static const bool useLocalApi = bool.fromEnvironment( + 'USE_LOCAL_API', + defaultValue: false, + ); + + /// Custom host override (e.g. --dart-define=LOCAL_HOST=192.168.1.100:8888) + static const String customLocalHost = String.fromEnvironment( + 'LOCAL_HOST', + defaultValue: '', + ); + + /// Production server domain + static const String defaultServerHost = 'eva.nexacode.dev'; + + /// Local development hosts + static const String localServerHost = '127.0.0.1:8888'; + static const String emulatorServerHost = '10.0.2.2:8888'; + + /// Active host resolution + static String get serverHost { + if (customLocalHost.isNotEmpty) return customLocalHost; + if (useLocalApi) return localServerHost; + return defaultServerHost; + } + + /// Whether the connection uses SSL (HTTPS / WSS) + static bool get isSecure => !useLocalApi && customLocalHost.isEmpty; + + /// Base API URL for psychologist endpoints + static String get apiBaseUrl { + final scheme = isSecure ? 'https' : 'http'; + return '$scheme://$serverHost/api/v1/counselor'; + } + + /// Base WebSocket URL for real-time video signaling & chat + static String get wsBaseUrl { + final scheme = isSecure ? 'wss' : 'ws'; + return '$scheme://$serverHost/api/v1/counselor/ws'; + } + + /// Request timeout duration + static const Duration connectTimeout = Duration(seconds: 15); + static const Duration receiveTimeout = Duration(seconds: 30); +} diff --git a/apps/eva_counselor_mobile/lib/core/network/api_client.dart b/apps/eva_counselor_mobile/lib/core/network/api_client.dart new file mode 100644 index 0000000..35f3ab1 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/network/api_client.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../config/app_config.dart'; +import '../storage/token_storage.dart'; + +class ApiException implements Exception { + final int statusCode; + final String message; + final dynamic details; + + ApiException({ + required this.statusCode, + required this.message, + this.details, + }); + + @override + String toString() => 'ApiException [$statusCode]: $message'; +} + +/// Centralized HTTP client managing auth header injection and status validation. +class ApiClient { + final http.Client _client; + + ApiClient({http.Client? client}) : _client = client ?? http.Client(); + + Future> _buildHeaders({Map? extra}) async { + final token = await TokenStorage.getToken(); + return { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + ...?extra, + }; + } + + Uri _buildUri(String path, [Map? queryParams]) { + final base = AppConfig.apiBaseUrl; + final fullUrl = '$base$path'; + return Uri.parse(fullUrl).replace( + queryParameters: queryParams?.map((k, v) => MapEntry(k, v.toString())), + ); + } + + Future get(String path, {Map? queryParams}) async { + final uri = _buildUri(path, queryParams); + final headers = await _buildHeaders(); + final response = await _client.get(uri, headers: headers).timeout(AppConfig.connectTimeout); + return _handleResponse(response); + } + + Future post(String path, {dynamic body}) async { + final uri = _buildUri(path); + final headers = await _buildHeaders(); + final response = await _client + .post(uri, headers: headers, body: body != null ? jsonEncode(body) : null) + .timeout(AppConfig.connectTimeout); + return _handleResponse(response); + } + + Future put(String path, {dynamic body}) async { + final uri = _buildUri(path); + final headers = await _buildHeaders(); + final response = await _client + .put(uri, headers: headers, body: body != null ? jsonEncode(body) : null) + .timeout(AppConfig.connectTimeout); + return _handleResponse(response); + } + + Future delete(String path) async { + final uri = _buildUri(path); + final headers = await _buildHeaders(); + final response = await _client.delete(uri, headers: headers).timeout(AppConfig.connectTimeout); + return _handleResponse(response); + } + + dynamic _handleResponse(http.Response res) { + dynamic decoded; + try { + decoded = res.body.isNotEmpty ? jsonDecode(res.body) : null; + } catch (_) { + decoded = res.body; + } + + if (res.statusCode >= 200 && res.statusCode < 300) { + return decoded; + } + + String message = 'Terjadi kesalahan sistem (${res.statusCode})'; + if (decoded is Map) { + message = decoded['detail']?.toString() ?? + decoded['message']?.toString() ?? + message; + } + + throw ApiException( + statusCode: res.statusCode, + message: message, + details: decoded, + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/core/network/api_endpoints.dart b/apps/eva_counselor_mobile/lib/core/network/api_endpoints.dart new file mode 100644 index 0000000..80600a5 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/network/api_endpoints.dart @@ -0,0 +1,34 @@ +/// Centralized backend API route constants for Counselor Application. +class ApiEndpoints { + // Auth & Profile + static const String login = '/auth/login'; + static const String register = '/auth/register'; + static const String profile = '/auth/me'; + static const String updateProfile = '/auth/profile'; + static const String uploadCertificate = '/auth/upload-certificate'; + + // Patients & Consent + static const String patients = '/patients'; + static String patientDetail(String id) => '/patients/$id'; + static String patientEmotions(String id) => '/patients/$id/emotions'; + static String patientDassHistory(String id) => '/patients/$id/dass-history'; + static String patientDiaries(String id) => '/patients/$id/diaries'; + static String requestConsent(String id) => '/patients/$id/request-consent'; + static String clinicalNotes(String id) => '/patients/$id/clinical-notes'; + + // Video Call & Teleconsultation + static const String activeSessions = '/sessions'; + static String sessionDetail(String id) => '/sessions/$id'; + static String startSession(String id) => '/sessions/$id/start'; + static String endSession(String id) => '/sessions/$id/end'; + static String sessionSignaling(String id) => '/sessions/$id/signal'; + + // Financials & Transactions + static const String financialSummary = '/finances/summary'; + static const String transactions = '/finances/transactions'; + static const String requestPayout = '/finances/payout'; + + // Availability / Schedule + static const String availability = '/schedule/availability'; + static const String appointments = '/schedule/appointments'; +} diff --git a/apps/eva_counselor_mobile/lib/core/routes/app_route_transitions.dart b/apps/eva_counselor_mobile/lib/core/routes/app_route_transitions.dart new file mode 100644 index 0000000..351e25e --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/routes/app_route_transitions.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class FadeThroughPageRoute extends PageRouteBuilder { + final Widget page; + + FadeThroughPageRoute({required this.page, super.settings}) + : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeInOutCubic, + ), + child: child, + ); + }, + transitionDuration: const Duration(milliseconds: 250), + ); +} diff --git a/apps/eva_counselor_mobile/lib/core/routes/app_router.dart b/apps/eva_counselor_mobile/lib/core/routes/app_router.dart new file mode 100644 index 0000000..c463540 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/routes/app_router.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import '../../features/auth/presentation/screens/psychologist_login_screen.dart'; +import '../../features/auth/presentation/screens/psychologist_registration_screen.dart'; +import '../../features/dashboard/presentation/screens/counselor_main_shell_screen.dart'; +import '../../features/patients/presentation/screens/patient_detail_consent_screen.dart'; +import '../../features/patients/presentation/screens/patient_list_screen.dart'; +import '../../features/video_call/presentation/screens/video_consultation_screen.dart'; +import '../../features/finances/presentation/screens/transaction_history_screen.dart'; +import '../../features/dashboard/presentation/screens/counselor_notification_screen.dart'; +import 'app_route_transitions.dart'; + +class AppRouter { + static Route? onGenerateRoute(RouteSettings settings) { + switch (settings.name) { + case '/': + case '/counselor-login': + return FadeThroughPageRoute( + page: const PsychologistLoginScreen(), + settings: settings, + ); + + case '/counselor-register': + return FadeThroughPageRoute( + page: const PsychologistRegistrationScreen(), + settings: settings, + ); + + case '/counselor-shell': + return FadeThroughPageRoute( + page: const CounselorMainShellScreen(), + settings: settings, + ); + + case '/counselor-notifications': + return FadeThroughPageRoute( + page: const CounselorNotificationScreen(), + settings: settings, + ); + + case '/counselor-patients': + return FadeThroughPageRoute( + page: const PatientListScreen(), + settings: settings, + ); + + case '/counselor-patient-detail': + final patientId = settings.arguments as String? ?? 'patient-001'; + return FadeThroughPageRoute( + page: PatientDetailConsentScreen(patientId: patientId), + settings: settings, + ); + + case '/counselor-video-call': + final sessionId = settings.arguments as String? ?? 'session-live-001'; + return FadeThroughPageRoute( + page: VideoConsultationScreen(sessionId: sessionId), + settings: settings, + ); + + case '/counselor-finances': + return FadeThroughPageRoute( + page: const TransactionHistoryScreen(), + settings: settings, + ); + + default: + return FadeThroughPageRoute( + page: const PsychologistLoginScreen(), + settings: settings, + ); + } + } +} diff --git a/apps/eva_counselor_mobile/lib/core/storage/token_storage.dart b/apps/eva_counselor_mobile/lib/core/storage/token_storage.dart new file mode 100644 index 0000000..515448f --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/storage/token_storage.dart @@ -0,0 +1,45 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Secure local storage helper for Psychologist JWT tokens and session data. +class TokenStorage { + static const String _keyToken = 'eva_counselor_token'; + static const String _keyUserId = 'eva_counselor_user_id'; + static const String _keyCounselorProfile = 'eva_counselor_profile_json'; + + static Future saveToken(String token) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyToken, token); + } + + static Future getToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keyToken); + } + + static Future saveUserId(String userId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyUserId, userId); + } + + static Future getUserId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keyUserId); + } + + static Future saveProfileJson(String profileJson) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyCounselorProfile, profileJson); + } + + static Future getProfileJson() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keyCounselorProfile); + } + + static Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyToken); + await prefs.remove(_keyUserId); + await prefs.remove(_keyCounselorProfile); + } +} diff --git a/apps/eva_counselor_mobile/lib/core/theme/app_colors.dart b/apps/eva_counselor_mobile/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..5052067 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/theme/app_colors.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class AppColors { + // Brand Palette (consistent with EVA Ecosystem) + static const Color primary = Color(0xFF5358CB); // Periwinkle Primary + static const Color primaryLight = Color(0xFF8B93FF); // Highlight Accent + static const Color primaryContainer = Color(0xFFE8E9FF); // Soft Container + + static const Color secondary = Color(0xFFE2DAFF); // Lavender Secondary + static const Color secondaryDark = Color(0xFF605A79); // Lavender Dark + + static const Color tertiary = Color(0xFFA7E6FF); // Sky Blue Tertiary + static const Color tertiaryDark = Color(0xFF20667B); // Sky Blue Dark + + // Neutral Canvas + static const Color background = Color(0xFFF8F9FE); // Cool Tint Off-White + static const Color surface = Color(0xFFFFFFFF); + static const Color cardBg = Color(0xFFFFFFFF); + + // Text + static const Color textPrimary = Color(0xFF191C1F); + static const Color textSecondary = Color(0xFF767684); + static const Color textLight = Color(0xFF9EA0AB); + + // Glass Effect Tokens + static const Color glassBackground = Color(0xCCFFFFFF); // 80% opacity + static const Color glassBorder = Color(0x66FFFFFF); // 40% opacity + static const Color glassShadow = Color(0x145358CB); // 8% periwinkle shadow + static const Color divider = Color(0x0E5358CB); // Soft divider + + // Clinical Status Badges + static const Color verifiedGreen = Color(0xFF2E7D32); + static const Color verifiedBg = Color(0xFFE8F5E9); + static const Color pendingAmber = Color(0xFFED6C02); + static const Color pendingBg = Color(0xFFFFF4E5); + static const Color highRiskRed = Color(0xFFD32F2F); + static const Color highRiskBg = Color(0xFFFFEBEE); + + // Functional + static const Color success = Color(0xFF4CAF50); + static const Color warning = Color(0xFFFF9800); + static const Color error = Color(0xFFBA1A1A); +} diff --git a/apps/eva_counselor_mobile/lib/core/theme/app_theme.dart b/apps/eva_counselor_mobile/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..e2e873d --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/theme/app_theme.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'app_colors.dart'; + +class AppTheme { + static ThemeData get lightTheme { + final base = ThemeData.light(); + return base.copyWith( + scaffoldBackgroundColor: AppColors.background, + primaryColor: AppColors.primary, + colorScheme: const ColorScheme.light( + primary: AppColors.primary, + secondary: AppColors.secondary, + tertiary: AppColors.tertiary, + surface: AppColors.surface, + error: AppColors.error, + ), + textTheme: GoogleFonts.interTextTheme(base.textTheme), + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: true, + iconTheme: IconThemeData(color: AppColors.textPrimary), + ), + cardTheme: CardThemeData( + color: AppColors.surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: const BorderSide(color: AppColors.divider, width: 1), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/core/theme/app_typography.dart b/apps/eva_counselor_mobile/lib/core/theme/app_typography.dart new file mode 100644 index 0000000..b177975 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/theme/app_typography.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'app_colors.dart'; + +class AppTypography { + static TextStyle get headingLarge => GoogleFonts.inter( + fontSize: 26, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: -0.5, + ); + + static TextStyle get headingMedium => GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + letterSpacing: -0.3, + ); + + static TextStyle get headingSmall => GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ); + + static TextStyle get bodyLarge => GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w400, + color: AppColors.textPrimary, + height: 1.5, + ); + + static TextStyle get bodyMedium => GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + height: 1.45, + ); + + static TextStyle get bodySmall => GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w400, + color: AppColors.textLight, + ); + + static TextStyle get labelMedium => GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.primary, + letterSpacing: 0.2, + ); +} diff --git a/apps/eva_counselor_mobile/lib/core/utils/responsive_layout_helper.dart b/apps/eva_counselor_mobile/lib/core/utils/responsive_layout_helper.dart new file mode 100644 index 0000000..201b685 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/utils/responsive_layout_helper.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +extension ResponsiveContext on BuildContext { + double get screenWidth => MediaQuery.of(this).size.width; + double get screenHeight => MediaQuery.of(this).size.height; + EdgeInsets get screenPadding => MediaQuery.of(this).padding; + + bool get isTablet => screenWidth >= 600; + bool get isDesktop => screenWidth >= 1024; + + double get maxAppWidth => isTablet ? 480.0 : double.infinity; +} diff --git a/apps/eva_counselor_mobile/lib/core/widgets/custom_button.dart b/apps/eva_counselor_mobile/lib/core/widgets/custom_button.dart new file mode 100644 index 0000000..626ee9c --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/widgets/custom_button.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; + +enum ButtonVariant { primary, secondary, outline, danger } + +class CustomButton extends StatelessWidget { + final String label; + final VoidCallback? onPressed; + final bool isLoading; + final ButtonVariant variant; + final IconData? icon; + final double height; + final double? width; + + const CustomButton({ + super.key, + required this.label, + required this.onPressed, + this.isLoading = false, + this.variant = ButtonVariant.primary, + this.icon, + this.height = 50.0, + this.width, + }); + + @override + Widget build(BuildContext context) { + Color bg; + Color fg; + Border? border; + + switch (variant) { + case ButtonVariant.primary: + bg = AppColors.primary; + fg = Colors.white; + break; + case ButtonVariant.secondary: + bg = AppColors.primaryContainer; + fg = AppColors.primary; + break; + case ButtonVariant.outline: + bg = Colors.transparent; + fg = AppColors.primary; + border = Border.all(color: AppColors.primary, width: 1.5); + break; + case ButtonVariant.danger: + bg = AppColors.error; + fg = Colors.white; + break; + } + + final disabled = onPressed == null || isLoading; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: height, + width: width ?? double.infinity, + decoration: BoxDecoration( + color: disabled ? bg.withOpacity(0.5) : bg, + borderRadius: BorderRadius.circular(16.0), + border: border, + boxShadow: variant == ButtonVariant.primary && !disabled + ? [ + BoxShadow( + color: AppColors.primary.withOpacity(0.3), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ] + : null, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16.0), + onTap: disabled ? null : onPressed, + child: Center( + child: isLoading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(fg), + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: fg, size: 18), + const SizedBox(width: 8), + ], + Text( + label, + style: AppTypography.headingSmall.copyWith( + color: fg, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/core/widgets/custom_text_field.dart b/apps/eva_counselor_mobile/lib/core/widgets/custom_text_field.dart new file mode 100644 index 0000000..2f6f9c1 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/widgets/custom_text_field.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; + +class CustomTextField extends StatelessWidget { + final String label; + final String? hint; + final TextEditingController? controller; + final bool obscureText; + final TextInputType? keyboardType; + final IconData? prefixIcon; + final Widget? suffixIcon; + final String? Function(String?)? validator; + final int maxLines; + final bool readOnly; + final VoidCallback? onTap; + + const CustomTextField({ + super.key, + required this.label, + this.hint, + this.controller, + this.obscureText = false, + this.keyboardType, + this.prefixIcon, + this.suffixIcon, + this.validator, + this.maxLines = 1, + this.readOnly = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: AppTypography.bodySmall.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + TextFormField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + maxLines: maxLines, + readOnly: readOnly, + onTap: onTap, + validator: validator, + style: AppTypography.bodyLarge.copyWith(fontSize: 14), + decoration: InputDecoration( + hintText: hint, + hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.textLight), + filled: true, + fillColor: Colors.white, + prefixIcon: prefixIcon != null + ? Icon(prefixIcon, color: AppColors.primary, size: 20) + : null, + suffixIcon: suffixIcon, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.divider), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.divider), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + ], + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/core/widgets/glass_card.dart b/apps/eva_counselor_mobile/lib/core/widgets/glass_card.dart new file mode 100644 index 0000000..9e439fe --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/widgets/glass_card.dart @@ -0,0 +1,57 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; + +class GlassCard extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry? padding; + final double borderRadius; + final VoidCallback? onTap; + final Color? backgroundColor; + final Border? customBorder; + + const GlassCard({ + super.key, + required this.child, + this.padding, + this.borderRadius = 20.0, + this.onTap, + this.backgroundColor, + this.customBorder, + }); + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 14.0, sigmaY: 14.0), + child: Container( + decoration: BoxDecoration( + color: backgroundColor ?? AppColors.glassBackground, + borderRadius: BorderRadius.circular(borderRadius), + border: customBorder ?? Border.all(color: AppColors.glassBorder, width: 1.0), + boxShadow: const [ + BoxShadow( + color: AppColors.glassShadow, + blurRadius: 20, + offset: Offset(0, 8), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(borderRadius), + onTap: onTap, + child: Padding( + padding: padding ?? const EdgeInsets.all(16.0), + child: child, + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/core/widgets/status_badge.dart b/apps/eva_counselor_mobile/lib/core/widgets/status_badge.dart new file mode 100644 index 0000000..476785e --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/widgets/status_badge.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; + +class StatusBadge extends StatelessWidget { + final String label; + final Color backgroundColor; + final Color textColor; + final IconData? icon; + + const StatusBadge({ + super.key, + required this.label, + required this.backgroundColor, + required this.textColor, + this.icon, + }); + + factory StatusBadge.verified([String label = 'Terverifikasi']) { + return StatusBadge( + label: label, + backgroundColor: AppColors.verifiedBg, + textColor: AppColors.verifiedGreen, + icon: Icons.verified_rounded, + ); + } + + factory StatusBadge.pending([String label = 'Menunggu Verifikasi']) { + return StatusBadge( + label: label, + backgroundColor: AppColors.pendingBg, + textColor: AppColors.pendingAmber, + icon: Icons.hourglass_top_rounded, + ); + } + + factory StatusBadge.highRisk([String label = 'Perhatian Khusus']) { + return StatusBadge( + label: label, + backgroundColor: AppColors.highRiskBg, + textColor: AppColors.highRiskRed, + icon: Icons.warning_amber_rounded, + ); + } + + factory StatusBadge.consentGranted([String label = 'Izin Akses Diberikan']) { + return StatusBadge( + label: label, + backgroundColor: AppColors.primaryContainer, + textColor: AppColors.primary, + icon: Icons.lock_open_rounded, + ); + } + + factory StatusBadge.consentLocked([String label = 'Akses Terkunci']) { + return StatusBadge( + label: label, + backgroundColor: Colors.black.withOpacity(0.06), + textColor: AppColors.textSecondary, + icon: Icons.lock_outline_rounded, + ); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: textColor), + const SizedBox(width: 4), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.bodySmall.copyWith( + color: textColor, + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/core/widgets/top_toast.dart b/apps/eva_counselor_mobile/lib/core/widgets/top_toast.dart new file mode 100644 index 0000000..e89cd10 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/widgets/top_toast.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +enum ToastType { success, warning, error, info } + +class TopToast { + static void show( + BuildContext context, { + required String message, + String? title, + ToastType type = ToastType.success, + }) { + final overlay = Overlay.of(context); + late OverlayEntry entry; + + Color bg; + Color iconColor; + IconData icon; + + switch (type) { + case ToastType.success: + bg = const Color(0xFF1B5E20); + iconColor = Colors.white; + icon = Icons.check_circle_rounded; + break; + case ToastType.warning: + bg = const Color(0xFFE65100); + iconColor = Colors.white; + icon = Icons.warning_rounded; + break; + case ToastType.error: + bg = const Color(0xFFB71C1C); + iconColor = Colors.white; + icon = Icons.error_rounded; + break; + case ToastType.info: + bg = const Color(0xFF303F9F); + iconColor = Colors.white; + icon = Icons.info_rounded; + break; + } + + entry = OverlayEntry( + builder: (context) => _TopToastWidget( + title: title, + message: message, + backgroundColor: bg, + icon: icon, + iconColor: iconColor, + onDismiss: () => entry.remove(), + ), + ); + + overlay.insert(entry); + } +} + +class _TopToastWidget extends StatefulWidget { + final String? title; + final String message; + final Color backgroundColor; + final IconData icon; + final Color iconColor; + final VoidCallback onDismiss; + + const _TopToastWidget({ + this.title, + required this.message, + required this.backgroundColor, + required this.icon, + required this.iconColor, + required this.onDismiss, + }); + + @override + State<_TopToastWidget> createState() => _TopToastWidgetState(); +} + +class _TopToastWidgetState extends State<_TopToastWidget> + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + late Animation _offsetAnim; + late Animation _fadeAnim; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + ); + _offsetAnim = Tween( + begin: const Offset(0, -0.6), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutBack)); + _fadeAnim = CurvedAnimation(parent: _ctrl, curve: Curves.easeIn); + + _ctrl.forward(); + + // Auto dismiss after 2.8 seconds + Future.delayed(const Duration(milliseconds: 2800), () { + if (mounted) { + _ctrl.reverse().then((_) => widget.onDismiss()); + } + }); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final topPadding = MediaQuery.of(context).padding.top; + + return Positioned( + top: topPadding + 10, + left: 16, + right: 16, + child: Material( + color: Colors.transparent, + child: FadeTransition( + opacity: _fadeAnim, + child: SlideTransition( + position: _offsetAnim, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: widget.backgroundColor, + borderRadius: BorderRadius.circular(18), + boxShadow: const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 18, + offset: Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + Icon(widget.icon, color: widget.iconColor, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.title != null) ...[ + Text( + widget.title!, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + const SizedBox(height: 2), + ], + Text( + widget.message, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + height: 1.3, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/core/widgets/web_phone_frame_wrapper.dart b/apps/eva_counselor_mobile/lib/core/widgets/web_phone_frame_wrapper.dart new file mode 100644 index 0000000..12e0a03 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/core/widgets/web_phone_frame_wrapper.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class WebPhoneFrameWrapper extends StatelessWidget { + final Widget child; + + const WebPhoneFrameWrapper({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + if (!kIsWeb) return child; + + final mediaQuery = MediaQuery.of(context); + if (mediaQuery.size.width <= 500) { + return child; + } + + return Scaffold( + backgroundColor: const Color(0xFF1E1E2C), + body: Center( + child: Container( + width: 420, + height: 880, + margin: const EdgeInsets.symmetric(vertical: 24), + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(48), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.5), + blurRadius: 30, + offset: const Offset(0, 15), + ), + ], + border: Border.all(color: const Color(0xFF333344), width: 3), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(44), + child: child, + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/auth/data/datasources/auth_datasource.dart b/apps/eva_counselor_mobile/lib/features/auth/data/datasources/auth_datasource.dart new file mode 100644 index 0000000..c0c26b1 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/auth/data/datasources/auth_datasource.dart @@ -0,0 +1,8 @@ +import '../models/psychologist_auth_dto.dart'; + +abstract class AuthDataSource { + Future login(PsychologistLoginRequestDto request); + Future register(PsychologistRegisterRequestDto request); + Future getProfile(); + Future updateAvailability(bool isOnline); +} diff --git a/apps/eva_counselor_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart b/apps/eva_counselor_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart new file mode 100644 index 0000000..9098be9 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart @@ -0,0 +1,84 @@ +import '../models/psychologist_auth_dto.dart'; +import 'auth_datasource.dart'; + +class MockAuthDataSource implements AuthDataSource { + bool _mockOnlineState = true; + + @override + Future login( + PsychologistLoginRequestDto request) async { + await Future.delayed(const Duration(milliseconds: 600)); + return PsychologistProfileResponseDto( + id: 'counselor-uuid-001', + fullName: 'Sarah Anindya', + titleDegree: 'M.Psi., Psikolog Klinis', + email: request.email, + strNumber: 'STR-PK-198827364-2024', + association: 'Himpunan Psikologi Indonesia (HIMPSI)', + specializations: [ + 'Kecemasan Dewasa & Burnout', + 'Manajemen Stres', + 'Konseling Hubungan & Keluarga', + ], + yearsOfExperience: 5, + consultationRatePerHour: 200000, + verificationStatus: 'verified', + isOnline: _mockOnlineState, + profilePhotoUrl: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400', + certificateDocUrl: 'https://eva.nexacode.dev/docs/sample_str.pdf', + token: 'mock-jwt-token-sarah-counselor-secret-xyz', + ); + } + + @override + Future register( + PsychologistRegisterRequestDto request) async { + await Future.delayed(const Duration(milliseconds: 800)); + return PsychologistProfileResponseDto( + id: 'counselor-uuid-new', + fullName: request.fullName, + titleDegree: request.titleDegree, + email: request.email, + strNumber: request.strNumber, + association: request.association, + specializations: request.specializations, + yearsOfExperience: request.yearsOfExperience, + consultationRatePerHour: request.consultationRate, + verificationStatus: 'pending', // Freshly uploaded certificate requires review + isOnline: false, + certificateDocUrl: request.certificateDocFileName, + token: 'mock-jwt-pending-verification-token', + ); + } + + @override + Future getProfile() async { + await Future.delayed(const Duration(milliseconds: 300)); + return PsychologistProfileResponseDto( + id: 'counselor-uuid-001', + fullName: 'Sarah Anindya', + titleDegree: 'M.Psi., Psikolog Klinis', + email: 'sarah.anindya@psikolog.eva.id', + strNumber: 'STR-PK-198827364-2024', + association: 'Himpunan Psikologi Indonesia (HIMPSI)', + specializations: [ + 'Kecemasan Dewasa & Burnout', + 'Manajemen Stres', + 'Konseling Hubungan & Keluarga', + ], + yearsOfExperience: 5, + consultationRatePerHour: 200000, + verificationStatus: 'verified', + isOnline: _mockOnlineState, + profilePhotoUrl: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400', + token: 'mock-jwt-token-sarah-counselor-secret-xyz', + ); + } + + @override + Future updateAvailability(bool isOnline) async { + await Future.delayed(const Duration(milliseconds: 300)); + _mockOnlineState = isOnline; + return true; + } +} diff --git a/apps/eva_counselor_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart b/apps/eva_counselor_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart new file mode 100644 index 0000000..5312add --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart @@ -0,0 +1,39 @@ +import '../../../../core/network/api_client.dart'; +import '../../../../core/network/api_endpoints.dart'; +import '../models/psychologist_auth_dto.dart'; +import 'auth_datasource.dart'; + +class RemoteAuthDataSource implements AuthDataSource { + final ApiClient _client; + + RemoteAuthDataSource({ApiClient? client}) : _client = client ?? ApiClient(); + + @override + Future login( + PsychologistLoginRequestDto request) async { + final response = await _client.post(ApiEndpoints.login, body: request.toJson()); + return PsychologistProfileResponseDto.fromJson(response as Map); + } + + @override + Future register( + PsychologistRegisterRequestDto request) async { + final response = await _client.post(ApiEndpoints.register, body: request.toJson()); + return PsychologistProfileResponseDto.fromJson(response as Map); + } + + @override + Future getProfile() async { + final response = await _client.get(ApiEndpoints.profile); + return PsychologistProfileResponseDto.fromJson(response as Map); + } + + @override + Future updateAvailability(bool isOnline) async { + final response = await _client.post( + ApiEndpoints.availability, + body: {'is_online': isOnline}, + ); + return (response as Map?)?['success'] as bool? ?? true; + } +} diff --git a/apps/eva_counselor_mobile/lib/features/auth/data/models/psychologist_auth_dto.dart b/apps/eva_counselor_mobile/lib/features/auth/data/models/psychologist_auth_dto.dart new file mode 100644 index 0000000..4590646 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/auth/data/models/psychologist_auth_dto.dart @@ -0,0 +1,168 @@ +import '../../domain/entities/psychologist_user_entity.dart'; + +/// Request schema for psychologist login. +class PsychologistLoginRequestDto { + final String email; + final String password; + + const PsychologistLoginRequestDto({ + required this.email, + required this.password, + }); + + Map toJson() => { + 'email': email, + 'password': password, + }; +} + +/// Request schema for psychologist registration with certification details. +class PsychologistRegisterRequestDto { + final String fullName; + final String titleDegree; + final String email; + final String password; + final String strNumber; + final String association; + final List specializations; + final int yearsOfExperience; + final int consultationRate; + final String certificateDocFileName; + + const PsychologistRegisterRequestDto({ + required this.fullName, + required this.titleDegree, + required this.email, + required this.password, + required this.strNumber, + required this.association, + required this.specializations, + required this.yearsOfExperience, + required this.consultationRate, + required this.certificateDocFileName, + }); + + Map toJson() => { + 'full_name': fullName, + 'title_degree': titleDegree, + 'email': email, + 'password': password, + 'str_number': strNumber, + 'association': association, + 'specializations': specializations, + 'years_of_experience': yearsOfExperience, + 'consultation_rate_per_hour': consultationRate, + 'certificate_doc_file_name': certificateDocFileName, + }; +} + +/// Response schema for psychologist auth and profile endpoints. +class PsychologistProfileResponseDto { + final String id; + final String fullName; + final String titleDegree; + final String email; + final String strNumber; + final String association; + final List specializations; + final int yearsOfExperience; + final int consultationRatePerHour; + final String verificationStatus; + final bool isOnline; + final String? profilePhotoUrl; + final String? certificateDocUrl; + final String? token; + + const PsychologistProfileResponseDto({ + required this.id, + required this.fullName, + required this.titleDegree, + required this.email, + required this.strNumber, + required this.association, + required this.specializations, + required this.yearsOfExperience, + required this.consultationRatePerHour, + required this.verificationStatus, + required this.isOnline, + this.profilePhotoUrl, + this.certificateDocUrl, + this.token, + }); + + factory PsychologistProfileResponseDto.fromJson(Map json) { + return PsychologistProfileResponseDto( + id: json['id']?.toString() ?? '', + fullName: json['full_name']?.toString() ?? '', + titleDegree: json['title_degree']?.toString() ?? 'M.Psi., Psikolog', + email: json['email']?.toString() ?? '', + strNumber: json['str_number']?.toString() ?? '', + association: json['association']?.toString() ?? 'HIMPSI', + specializations: (json['specializations'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + yearsOfExperience: json['years_of_experience'] as int? ?? 1, + consultationRatePerHour: + json['consultation_rate_per_hour'] as int? ?? 150000, + verificationStatus: + json['verification_status']?.toString() ?? 'pending', + isOnline: json['is_online'] as bool? ?? true, + profilePhotoUrl: json['profile_photo_url']?.toString(), + certificateDocUrl: json['certificate_doc_url']?.toString(), + token: json['token']?.toString(), + ); + } + + Map toJson() => { + 'id': id, + 'full_name': fullName, + 'title_degree': titleDegree, + 'email': email, + 'str_number': strNumber, + 'association': association, + 'specializations': specializations, + 'years_of_experience': yearsOfExperience, + 'consultation_rate_per_hour': consultationRatePerHour, + 'verification_status': verificationStatus, + 'is_online': isOnline, + 'profile_photo_url': profilePhotoUrl, + 'certificate_doc_url': certificateDocUrl, + if (token != null) 'token': token, + }; + + PsychologistUserEntity toEntity() { + VerificationStatus status; + switch (verificationStatus.toLowerCase()) { + case 'verified': + status = VerificationStatus.verified; + break; + case 'need_revision': + status = VerificationStatus.needRevision; + break; + case 'rejected': + status = VerificationStatus.rejected; + break; + case 'pending': + default: + status = VerificationStatus.pending; + break; + } + + return PsychologistUserEntity( + id: id, + fullName: fullName, + titleDegree: titleDegree, + email: email, + strNumber: strNumber, + association: association, + specializations: specializations, + yearsOfExperience: yearsOfExperience, + consultationRatePerHour: consultationRatePerHour, + verificationStatus: status, + isOnline: isOnline, + profilePhotoUrl: profilePhotoUrl, + certificateDocUrl: certificateDocUrl, + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart b/apps/eva_counselor_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart new file mode 100644 index 0000000..daa5a2a --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; +import '../../../../core/storage/token_storage.dart'; +import '../../domain/entities/psychologist_user_entity.dart'; +import '../../domain/repositories/auth_repository.dart'; +import '../datasources/auth_datasource.dart'; +import '../models/psychologist_auth_dto.dart'; + +class AuthRepositoryImpl implements IAuthRepository { + final AuthDataSource _dataSource; + + AuthRepositoryImpl({required AuthDataSource dataSource}) + : _dataSource = dataSource; + + @override + Future login(String email, String password) async { + final dto = await _dataSource.login( + PsychologistLoginRequestDto(email: email, password: password), + ); + if (dto.token != null) { + await TokenStorage.saveToken(dto.token!); + } + await TokenStorage.saveUserId(dto.id); + await TokenStorage.saveProfileJson(jsonEncode(dto.toJson())); + return dto.toEntity(); + } + + @override + Future register({ + required String fullName, + required String titleDegree, + required String email, + required String password, + required String strNumber, + required String association, + required List specializations, + required int yearsOfExperience, + required int consultationRate, + required String certificateDocFileName, + }) async { + final request = PsychologistRegisterRequestDto( + fullName: fullName, + titleDegree: titleDegree, + email: email, + password: password, + strNumber: strNumber, + association: association, + specializations: specializations, + yearsOfExperience: yearsOfExperience, + consultationRate: consultationRate, + certificateDocFileName: certificateDocFileName, + ); + final dto = await _dataSource.register(request); + if (dto.token != null) { + await TokenStorage.saveToken(dto.token!); + } + await TokenStorage.saveUserId(dto.id); + await TokenStorage.saveProfileJson(jsonEncode(dto.toJson())); + return dto.toEntity(); + } + + @override + Future getCurrentProfile() async { + try { + final cachedJson = await TokenStorage.getProfileJson(); + if (cachedJson != null && cachedJson.isNotEmpty) { + final decoded = jsonDecode(cachedJson) as Map; + return PsychologistProfileResponseDto.fromJson(decoded).toEntity(); + } + final dto = await _dataSource.getProfile(); + return dto.toEntity(); + } catch (_) { + return null; + } + } + + @override + Future setOnlineAvailability(bool isOnline) async { + return await _dataSource.updateAvailability(isOnline); + } + + @override + Future logout() async { + await TokenStorage.clear(); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/auth/domain/entities/psychologist_user_entity.dart b/apps/eva_counselor_mobile/lib/features/auth/domain/entities/psychologist_user_entity.dart new file mode 100644 index 0000000..57b5f0a --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/auth/domain/entities/psychologist_user_entity.dart @@ -0,0 +1,35 @@ +enum VerificationStatus { pending, verified, needRevision, rejected } + +class PsychologistUserEntity { + final String id; + final String fullName; + final String titleDegree; // e.g., 'S.Psi., M.Psi., Psikolog' + final String email; + final String strNumber; // Surat Tanda Registrasi + final String association; // e.g. 'HIMPSI' + final List specializations; + final int yearsOfExperience; + final int consultationRatePerHour; + final VerificationStatus verificationStatus; + final bool isOnline; + final String? profilePhotoUrl; + final String? certificateDocUrl; + + const PsychologistUserEntity({ + required this.id, + required this.fullName, + required this.titleDegree, + required this.email, + required this.strNumber, + required this.association, + required this.specializations, + required this.yearsOfExperience, + required this.consultationRatePerHour, + required this.verificationStatus, + this.isOnline = true, + this.profilePhotoUrl, + this.certificateDocUrl, + }); + + String get displayName => '$fullName, $titleDegree'; +} diff --git a/apps/eva_counselor_mobile/lib/features/auth/domain/repositories/auth_repository.dart b/apps/eva_counselor_mobile/lib/features/auth/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..3b7f320 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,20 @@ +import '../entities/psychologist_user_entity.dart'; + +abstract class IAuthRepository { + Future login(String email, String password); + Future register({ + required String fullName, + required String titleDegree, + required String email, + required String password, + required String strNumber, + required String association, + required List specializations, + required int yearsOfExperience, + required int consultationRate, + required String certificateDocFileName, + }); + Future getCurrentProfile(); + Future setOnlineAvailability(bool isOnline); + Future logout(); +} diff --git a/apps/eva_counselor_mobile/lib/features/auth/presentation/providers/auth_provider.dart b/apps/eva_counselor_mobile/lib/features/auth/presentation/providers/auth_provider.dart new file mode 100644 index 0000000..239ad43 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/auth/presentation/providers/auth_provider.dart @@ -0,0 +1,117 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/config/app_config.dart'; +import '../../domain/entities/psychologist_user_entity.dart'; +import '../../domain/repositories/auth_repository.dart'; +import '../../data/datasources/auth_datasource.dart'; +import '../../data/datasources/mock_auth_datasource.dart'; +import '../../data/datasources/remote_auth_datasource.dart'; +import '../../data/repositories/auth_repository_impl.dart'; + +/// Data source provider dynamically switching based on AppConfig.useMockData +final authDataSourceProvider = Provider((ref) { + if (AppConfig.useMockData) { + return MockAuthDataSource(); + } + return RemoteAuthDataSource(); +}); + +/// Repository provider +final authRepositoryProvider = Provider((ref) { + final dataSource = ref.watch(authDataSourceProvider); + return AuthRepositoryImpl(dataSource: dataSource); +}); + +/// Current active psychologist profile state notifier +class AuthStateNotifier extends StateNotifier> { + final IAuthRepository _repository; + + AuthStateNotifier(this._repository) : super(const AsyncValue.loading()) { + checkAuthSession(); + } + + Future checkAuthSession() async { + state = const AsyncValue.loading(); + try { + final user = await _repository.getCurrentProfile(); + state = AsyncValue.data(user); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future login(String email, String password) async { + state = const AsyncValue.loading(); + try { + final user = await _repository.login(email, password); + state = AsyncValue.data(user); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future register({ + required String fullName, + required String titleDegree, + required String email, + required String password, + required String strNumber, + required String association, + required List specializations, + required int yearsOfExperience, + required int consultationRate, + required String certificateDocFileName, + }) async { + state = const AsyncValue.loading(); + try { + final user = await _repository.register( + fullName: fullName, + titleDegree: titleDegree, + email: email, + password: password, + strNumber: strNumber, + association: association, + specializations: specializations, + yearsOfExperience: yearsOfExperience, + consultationRate: consultationRate, + certificateDocFileName: certificateDocFileName, + ); + state = AsyncValue.data(user); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future toggleAvailability(bool isOnline) async { + final current = state.value; + if (current == null) return; + try { + await _repository.setOnlineAvailability(isOnline); + state = AsyncValue.data(PsychologistUserEntity( + id: current.id, + fullName: current.fullName, + titleDegree: current.titleDegree, + email: current.email, + strNumber: current.strNumber, + association: current.association, + specializations: current.specializations, + yearsOfExperience: current.yearsOfExperience, + consultationRatePerHour: current.consultationRatePerHour, + verificationStatus: current.verificationStatus, + isOnline: isOnline, + profilePhotoUrl: current.profilePhotoUrl, + certificateDocUrl: current.certificateDocUrl, + )); + } catch (_) {} + } + + Future logout() async { + await _repository.logout(); + state = const AsyncValue.data(null); + } +} + +final authStateProvider = + StateNotifierProvider>((ref) { + final repo = ref.watch(authRepositoryProvider); + return AuthStateNotifier(repo); +}); diff --git a/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/psychologist_login_screen.dart b/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/psychologist_login_screen.dart new file mode 100644 index 0000000..937b6e1 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/psychologist_login_screen.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../core/widgets/custom_button.dart'; +import '../../../../core/widgets/custom_text_field.dart'; +import '../../../../core/widgets/glass_card.dart'; +import '../providers/auth_provider.dart'; + +class PsychologistLoginScreen extends ConsumerStatefulWidget { + const PsychologistLoginScreen({super.key}); + + @override + ConsumerState createState() => + _PsychologistLoginScreenState(); +} + +class _PsychologistLoginScreenState extends ConsumerState { + final _emailController = + TextEditingController(text: 'sarah.anindya@psikolog.eva.id'); + final _passwordController = TextEditingController(text: 'password123'); + bool _isLoading = false; + + void _handleLogin() async { + setState(() => _isLoading = true); + await ref + .read(authStateProvider.notifier) + .login(_emailController.text, _passwordController.text); + if (!mounted) return; + setState(() => _isLoading = false); + Navigator.of(context).pushReplacementNamed('/counselor-shell'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 20), + Container( + width: 76, + height: 76, + decoration: BoxDecoration( + color: AppColors.primaryContainer, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.15), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Center( + child: Image.asset( + 'assets/images/eva_logo.png', + width: 48, + height: 48, + errorBuilder: (_, __, ___) => const Icon( + Icons.psychology_rounded, + size: 40, + color: AppColors.primary, + ), + ), + ), + ), + const SizedBox(height: 20), + Text( + 'Portal Psikolog EVA', + style: AppTypography.headingLarge, + ), + const SizedBox(height: 8), + Text( + 'Kelola telekonsultasi, rekam psikologis pasien berizin, dan sesi video call.', + textAlign: TextAlign.center, + style: AppTypography.bodyMedium, + ), + const SizedBox(height: 36), + GlassCard( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + CustomTextField( + label: 'Email Profesional', + hint: 'nama@psikolog.eva.id', + controller: _emailController, + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + CustomTextField( + label: 'Kata Sandi', + hint: 'â€ĸâ€ĸâ€ĸâ€ĸâ€ĸâ€ĸâ€ĸâ€ĸ', + controller: _passwordController, + obscureText: true, + prefixIcon: Icons.lock_outline_rounded, + ), + const SizedBox(height: 24), + CustomButton( + label: 'Masuk Sebagai Psikolog', + isLoading: _isLoading, + onPressed: _handleLogin, + ), + ], + ), + ), + const SizedBox(height: 28), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Belum terdaftar sebagai mitra?', + style: AppTypography.bodyMedium, + ), + const SizedBox(width: 6), + GestureDetector( + onTap: () { + Navigator.of(context).pushNamed('/counselor-register'); + }, + child: Text( + 'Daftar & Upload STR', + style: AppTypography.bodySmall.copyWith( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/psychologist_registration_screen.dart b/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/psychologist_registration_screen.dart new file mode 100644 index 0000000..65ee737 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/psychologist_registration_screen.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../core/widgets/custom_button.dart'; +import '../../../../core/widgets/custom_text_field.dart'; +import '../../../../core/widgets/glass_card.dart'; +import '../providers/auth_provider.dart'; + +class PsychologistRegistrationScreen extends ConsumerStatefulWidget { + const PsychologistRegistrationScreen({super.key}); + + @override + ConsumerState createState() => + _PsychologistRegistrationScreenState(); +} + +class _PsychologistRegistrationScreenState + extends ConsumerState { + final _formKey = GlobalKey(); + + final _fullNameCtrl = TextEditingController(); + final _degreeCtrl = TextEditingController(text: 'M.Psi., Psikolog Klinis'); + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + final _strCtrl = TextEditingController(); + final _associationCtrl = + TextEditingController(text: 'Himpunan Psikologi Indonesia (HIMPSI)'); + final _rateCtrl = TextEditingController(text: '200000'); + + String? _uploadedFileName; + bool _isLoading = false; + + final List _availableSpecializations = [ + 'Kecemasan Dewasa', + 'Depresi & Suasana Hati', + 'Manajemen Stres & Burnout', + 'Konseling Remaja & Akademik', + 'Hubungan & Keluarga', + 'Trauma & PTSD', + ]; + final Set _selectedSpecializations = {'Kecemasan Dewasa', 'Manajemen Stres & Burnout'}; + + void _handlePickFile() { + setState(() { + _uploadedFileName = 'STR_Psikolog_${DateTime.now().year}_scan.pdf'; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Dokumen STR berhasil dipilih untuk verifikasi.'), + backgroundColor: AppColors.primary, + ), + ); + } + + void _handleSubmit() async { + if (!_formKey.currentState!.validate()) return; + if (_uploadedFileName == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mohon lampirkan scan STR/SIPP untuk verifikasi legalitas praktik.'), + backgroundColor: AppColors.error, + ), + ); + return; + } + + setState(() => _isLoading = true); + await ref.read(authStateProvider.notifier).register( + fullName: _fullNameCtrl.text, + titleDegree: _degreeCtrl.text, + email: _emailCtrl.text, + password: _passwordCtrl.text, + strNumber: _strCtrl.text, + association: _associationCtrl.text, + specializations: _selectedSpecializations.toList(), + yearsOfExperience: 3, + consultationRate: int.tryParse(_rateCtrl.text) ?? 200000, + certificateDocFileName: _uploadedFileName!, + ); + + if (!mounted) return; + setState(() => _isLoading = false); + + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: const [ + Icon(Icons.verified_user_outlined, color: AppColors.primary), + SizedBox(width: 8), + Text('Pendaftaran Terkirim'), + ], + ), + content: const Text( + 'Pendaftaran akun psikolog dan berkas STR Anda telah berhasil diunggah. Tim klinis EVA akan memverifikasi dokumen Anda dalam 1x24 jam kerja.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + Navigator.of(context).pushReplacementNamed('/counselor-shell'); + }, + child: const Text('Buka Dashboard Mitra'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Pendaftaran Mitra Psikolog', style: AppTypography.headingMedium), + ), + body: SafeArea( + child: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Kredensial Profesional', + style: AppTypography.headingSmall, + ), + const SizedBox(height: 4), + Text( + 'Pastikan data sesuai dengan sertifikasi resmi HIMPSI atau Kemenkes.', + style: AppTypography.bodyMedium, + ), + const SizedBox(height: 16), + GlassCard( + child: Column( + children: [ + CustomTextField( + label: 'Nama Lengkap (Tanpa Gelar)', + hint: 'cth: Sarah Anindya', + controller: _fullNameCtrl, + prefixIcon: Icons.person_outline, + validator: (v) => v!.isEmpty ? 'Wajib diisi' : null, + ), + const SizedBox(height: 14), + CustomTextField( + label: 'Gelar Akademik & Profesi', + hint: 'cth: M.Psi., Psikolog', + controller: _degreeCtrl, + prefixIcon: Icons.school_outlined, + ), + const SizedBox(height: 14), + CustomTextField( + label: 'Email Profesional', + hint: 'nama@psikolog.eva.id', + controller: _emailCtrl, + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + validator: (v) => v!.isEmpty ? 'Wajib diisi' : null, + ), + const SizedBox(height: 14), + CustomTextField( + label: 'Kata Sandi', + hint: 'â€ĸâ€ĸâ€ĸâ€ĸâ€ĸâ€ĸâ€ĸâ€ĸ', + controller: _passwordCtrl, + obscureText: true, + prefixIcon: Icons.lock_outline, + validator: (v) => (v?.length ?? 0) < 6 ? 'Min. 6 karakter' : null, + ), + const SizedBox(height: 14), + CustomTextField( + label: 'Nomor STR / SIPP Aktif', + hint: 'cth: STR-PK-198827364-2024', + controller: _strCtrl, + prefixIcon: Icons.badge_outlined, + validator: (v) => v!.isEmpty ? 'Nomor STR/SIPP wajib diisi' : null, + ), + const SizedBox(height: 14), + CustomTextField( + label: 'Tarif Konsultasi per Sesi (Rp)', + hint: '200000', + controller: _rateCtrl, + prefixIcon: Icons.payments_outlined, + keyboardType: TextInputType.number, + ), + ], + ), + ), + const SizedBox(height: 20), + Text('Fokus Spesialisasi Klinis', style: AppTypography.headingSmall), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: _availableSpecializations.map((spec) { + final isSelected = _selectedSpecializations.contains(spec); + return FilterChip( + selected: isSelected, + label: Text(spec), + selectedColor: AppColors.primaryContainer, + checkmarkColor: AppColors.primary, + labelStyle: AppTypography.bodySmall.copyWith( + color: isSelected ? AppColors.primary : AppColors.textPrimary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + ), + onSelected: (val) { + setState(() { + if (val) { + _selectedSpecializations.add(spec); + } else { + _selectedSpecializations.remove(spec); + } + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + Text('Unggah Berkas Legalitas (STR / SIPP)', style: AppTypography.headingSmall), + const SizedBox(height: 6), + Text( + 'Format PDF atau JPG/PNG (Maks 5MB) untuk diverifikasi oleh Dewan Etik EVA.', + style: AppTypography.bodyMedium, + ), + const SizedBox(height: 12), + InkWell( + onTap: _handlePickFile, + borderRadius: BorderRadius.circular(16), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withOpacity(0.4), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.primary.withOpacity(0.3), width: 1.5), + ), + child: Column( + children: [ + Icon( + _uploadedFileName != null + ? Icons.check_circle_outline_rounded + : Icons.cloud_upload_outlined, + size: 38, + color: _uploadedFileName != null + ? AppColors.verifiedGreen + : AppColors.primary, + ), + const SizedBox(height: 8), + Text( + _uploadedFileName ?? 'Klik untuk Memilih File Scan STR/SIPP', + style: AppTypography.headingSmall.copyWith( + fontSize: 14, + color: _uploadedFileName != null + ? AppColors.verifiedGreen + : AppColors.primary, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 32), + CustomButton( + label: 'Kirim Pendaftaran & Berkas', + isLoading: _isLoading, + onPressed: _handleSubmit, + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/providers/counselor_nav_provider.dart b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/providers/counselor_nav_provider.dart new file mode 100644 index 0000000..a1c8a22 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/providers/counselor_nav_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Provider for managing the active tab index in CounselorMainShellScreen. +/// 0: Beranda (Home Dashboard) +/// 1: Pasien (Patient List) +/// 2: Keuangan (Finances & Payouts) +/// 3: Profil (Profile & Practice Settings) +final counselorBottomNavIndexProvider = StateProvider((ref) => 0); diff --git a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_home_screen.dart b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_home_screen.dart new file mode 100644 index 0000000..f67a2eb --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_home_screen.dart @@ -0,0 +1,1436 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../../../../core/widgets/top_toast.dart'; +import '../../../auth/presentation/providers/auth_provider.dart'; +import '../../../patients/presentation/providers/patient_provider.dart'; +import '../providers/counselor_nav_provider.dart'; + +class CounselorHomeScreen extends ConsumerStatefulWidget { + const CounselorHomeScreen({super.key}); + + @override + ConsumerState createState() => _CounselorHomeScreenState(); +} + +class _CounselorHomeScreenState extends ConsumerState { + int _selectedCalendarIndex = 1; // 1 = Sel 11 (Hari Ini) + int _currentBookingIndex = 0; + late final PageController _bookingPageController = PageController(); + + @override + void dispose() { + _bookingPageController.dispose(); + super.dispose(); + } + + // MULTIPLE PENDING BOOKINGS + final List> _pendingBookings = [ + { + 'id': 'book-1', + 'patientName': 'Maya Saraswati (22 th)', + 'chiefComplaint': 'Burnout Skripsi & Beban Ekspektasi', + 'requestedSchedule': 'Besok Rabu, 12 Nov â€ĸ 14:00 - 14:50 WIB', + 'expiresIn': '01:45:00', + }, + { + 'id': 'book-2', + 'patientName': 'Dimas Setiawan (27 th)', + 'chiefComplaint': 'Kecemasan Karier & Problem Hubungan', + 'requestedSchedule': 'Kamis, 13 Nov â€ĸ 10:00 - 10:50 WIB', + 'expiresIn': '00:52:30', + }, + { + 'id': 'book-3', + 'patientName': 'Gita Savira (24 th)', + 'chiefComplaint': 'Insomnia Akut & Serangan Panik', + 'requestedSchedule': 'Jumat, 14 Nov â€ĸ 16:00 - 16:50 WIB', + 'expiresIn': '01:15:10', + }, + ]; + + // FULL 7-DAY WEEK (Senin s/d Minggu) + final List> _calendarDays = [ + { + 'day': 'Sen', + 'date': '10', + 'hasSession': true, + 'isHighRisk': false, + 'isOffDay': false, + 'sessions': [ + { + 'time': '10:00 - 10:50 WIB', + 'patientName': 'Aditya Nugraha', + 'chiefComplaint': 'Regulasi emosi & relasi sosial', + 'status': 'Selesai', + 'isCompleted': true, + 'patientId': 'patient-003', + }, + ], + }, + { + 'day': 'Sel', + 'date': '11', + 'isToday': true, + 'hasSession': true, + 'isHighRisk': true, + 'isOffDay': false, + 'sessions': [ + { + 'time': '10:00 - 10:50 WIB', + 'patientName': 'Rian Pratama (24 Thn)', + 'chiefComplaint': 'Kecemasan akademik & insomnia', + 'status': 'Mulai 15 Mnt Lagi', + 'isLive': true, + 'patientId': 'patient-001', + 'sessionId': 'session-live-001', + }, + { + 'time': '14:00 - 14:50 WIB', + 'patientName': 'Dinda Kirana (23 Thn)', + 'chiefComplaint': 'Burnout & serangan panik akut', + 'status': 'Perhatian Krisis 🚨', + 'isHighRisk': true, + 'patientId': 'patient-002', + }, + ], + }, + { + 'day': 'Rab', + 'date': '12', + 'hasSession': true, + 'isHighRisk': false, + 'isOffDay': false, + 'sessions': [ + { + 'time': '11:00 - 11:50 WIB', + 'patientName': 'Maya Lestari (21 Thn)', + 'chiefComplaint': 'Quarter-life crisis & eksplorasi karier', + 'status': 'Terkonfirmasi', + 'patientId': 'patient-004', + }, + ], + }, + { + 'day': 'Kam', + 'date': '13', + 'hasSession': false, + 'isHighRisk': false, + 'isOffDay': false, + 'sessions': [], + }, + { + 'day': 'Jum', + 'date': '14', + 'hasSession': true, + 'isHighRisk': false, + 'isOffDay': false, + 'sessions': [ + { + 'time': '13:30 - 14:20 WIB', + 'patientName': 'Budi Santoso', + 'chiefComplaint': 'Konseling evaluasi lanjutan', + 'status': 'Terkonfirmasi', + 'patientId': 'patient-001', + }, + ], + }, + { + 'day': 'Sab', + 'date': '15', + 'hasSession': false, + 'isHighRisk': false, + 'isOffDay': true, // LIBUR + 'sessions': [], + }, + { + 'day': 'Min', + 'date': '16', + 'hasSession': false, + 'isHighRisk': false, + 'isOffDay': true, // LIBUR + 'sessions': [], + }, + ]; + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authStateProvider); + final user = authState.value; + final patientsAsync = ref.watch(patientsListProvider); + + final selectedDayData = _calendarDays[_selectedCalendarIndex]; + final selectedSessions = (selectedDayData['sessions'] as List?) ?? []; + final isSelectedDayOff = selectedDayData['isOffDay'] == true; + + return Scaffold( + backgroundColor: const Color(0xFFF8F9FE), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. TOP BAR: Logo Resmi Luna & Notifikasi Halaman + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.08), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + padding: const EdgeInsets.all(5), + child: Image.asset( + 'assets/images/luna_logo_square.png', + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const Icon( + Icons.psychology_rounded, + color: AppColors.primary, + size: 20, + ), + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Text( + 'LUNA', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: AppColors.textPrimary, + ), + ), + SizedBox(width: 4), + Text( + 'COUNSELOR', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: AppColors.primary, + letterSpacing: 0.5, + ), + ), + ], + ), + Text( + 'Dashboard Konselor Klinis', + style: AppTypography.bodySmall.copyWith(fontSize: 11, color: AppColors.textSecondary), + ), + ], + ), + ], + ), + GestureDetector( + onTap: () { + Navigator.of(context).pushNamed('/counselor-notifications'); + }, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.notifications_none_rounded, + color: AppColors.textPrimary, + size: 20, + ), + ), + Positioned( + right: -2, + top: -2, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: AppColors.error, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white, width: 1.5), + ), + child: const Text( + '2', + style: TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // 2. PROFILE & STATUS PILL (SESUAI GAMBAR REFERENSI: BUKAN TOGGLE SWITCH) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Stack( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: AppColors.primaryContainer, + backgroundImage: user?.profilePhotoUrl != null + ? NetworkImage(user!.profilePhotoUrl!) + : null, + child: user?.profilePhotoUrl == null + ? const Icon(Icons.person, color: AppColors.primary) + : null, + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: AppColors.verifiedGreen, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user?.displayName ?? 'dr. Sarah Jenkins, M.Psi', + style: AppTypography.headingSmall.copyWith(fontSize: 15), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + children: const [ + Icon(Icons.verified_rounded, size: 13, color: AppColors.verifiedGreen), + SizedBox(width: 4), + Flexible( + child: Text( + 'STR Aktif #4192-PSI', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.verifiedGreen, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 8), + + // STATUS INFO PILL (Online / Menerima Konsultasi) + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppColors.verifiedGreen, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + const Text( + 'Online (Menerima Konsultasi)', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(10), + ), + child: const Text( + 'Aktif', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 18), + + // 3. UPCOMING TELECONSULTATION HERO CARD (No Overflows, Soft Gradient) + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF4B53BB), + Color(0xFF6B72E5), + ], + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF4B53BB).withValues(alpha: 0.28), + blurRadius: 24, + offset: const Offset(0, 10), + ), + ], + ), + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top Badges + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.access_time_rounded, color: Colors.white, size: 12), + SizedBox(width: 5), + Flexible( + child: Text( + 'Mulai dalam 15 Mnt â€ĸ 10:00 WIB', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(10), + ), + child: const Text( + 'Telekonsul', + style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Patient Photo + Clinical & Emotional Record + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 66, + height: 72, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: Colors.white.withValues(alpha: 0.2), + image: const DecorationImage( + image: NetworkImage( + 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=200&auto=format&fit=crop&q=80', + ), + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Rian Pratama (24 Thn)', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + const Text( + 'Generalized Anxiety & Insomnia', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.white70, fontSize: 11), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Text('😟', style: TextStyle(fontSize: 11)), + SizedBox(width: 4), + Flexible( + child: Text( + 'Cemas â€ĸ DASS-21: 18', + style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Actions + Row( + children: [ + Expanded( + flex: 2, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Colors.white54), + padding: const EdgeInsets.symmetric(vertical: 11), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () { + Navigator.of(context).pushNamed( + '/counselor-patient-detail', + arguments: 'patient-001', + ); + }, + child: const Text( + 'Rekam Medis', + style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 3, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.primary, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 11), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.videocam_rounded, size: 17), + label: const Text( + 'Masuk Sesi VC', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + onPressed: () { + Navigator.of(context).pushNamed( + '/counselor-video-call', + arguments: 'session-live-001', + ); + }, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 18), + + // 4. PERMINTAAN KONSULTASI BARU (DISTINCT ACTION CARD DENGAN WARM AMBER STYLING & FOCUSED PAGER) + if (_pendingBookings.isNotEmpty) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFD97706), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + 'Permintaan Booking (${_pendingBookings.length} Menunggu)', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: AppColors.textPrimary), + ), + ], + ), + if (_pendingBookings.length > 1) + Text( + '${_currentBookingIndex + 1} dari ${_pendingBookings.length}', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Color(0xFFB45309)), + ), + ], + ), + const SizedBox(height: 10), + SizedBox( + height: 222, + child: PageView.builder( + controller: _bookingPageController, + itemCount: _pendingBookings.length, + onPageChanged: (i) => setState(() => _currentBookingIndex = i), + itemBuilder: (context, idx) { + final b = _pendingBookings[idx]; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFFFBF3), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: const Color(0xFFFFDFB0), width: 1.4), + boxShadow: [ + BoxShadow( + color: const Color(0xFFD97706).withValues(alpha: 0.08), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Header: Tag Konsultasi Baru & Expiry Pill + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.mark_email_unread_rounded, size: 14, color: Color(0xFFD97706)), + ), + const SizedBox(width: 6), + const Text( + 'PERMINTAAN KONSULTASI BARU', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w800, + color: Color(0xFFB45309), + letterSpacing: 0.4, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.timer_outlined, size: 11, color: Color(0xFFDC2626)), + const SizedBox(width: 4), + Text( + 'Sisa ${b['expiresIn'] ?? "01:30:00"}', + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Color(0xFFDC2626), + ), + ), + ], + ), + ), + ], + ), + + // Patient Profile & Problem + Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: const Color(0xFFFFEDD5), + child: Text( + (b['patientName'] as String).substring(0, 1), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Color(0xFFC2410C)), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + b['patientName'] as String, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: AppColors.textPrimary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 1), + Text( + b['chiefComplaint'] as String, + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + + // Proposed Schedule Capsule (Clear contrast box) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFF1E5D5)), + ), + child: Row( + children: [ + const Icon(Icons.calendar_month_rounded, size: 14, color: AppColors.primary), + const SizedBox(width: 6), + const Text('Jadwal: ', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + Expanded( + child: Text( + b['requestedSchedule'] as String, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 11, color: AppColors.textPrimary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + // Action Buttons (Terima vs Jadwal Lain) + Row( + children: [ + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + backgroundColor: Colors.white, + side: const BorderSide(color: Color(0xFFD6D9E4)), + padding: const EdgeInsets.symmetric(vertical: 9), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () { + final name = b['patientName']; + setState(() { + _pendingBookings.removeAt(idx); + if (_currentBookingIndex >= _pendingBookings.length && _currentBookingIndex > 0) { + _currentBookingIndex = _pendingBookings.length - 1; + } + }); + TopToast.show( + context, + title: 'Opsi Reschedule Dikirim', + message: 'Tawaran opsi jadwal alternatif telah dikirimkan ke $name.', + type: ToastType.info, + ); + }, + child: const Text('Jadwal Lain', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), + ), + ), + const SizedBox(width: 10), + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 9), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.check_rounded, size: 16), + label: const Text('Terima Booking', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + onPressed: () { + final name = b['patientName']; + setState(() { + _pendingBookings.removeAt(idx); + if (_currentBookingIndex >= _pendingBookings.length && _currentBookingIndex > 0) { + _currentBookingIndex = _pendingBookings.length - 1; + } + }); + TopToast.show( + context, + title: 'Booking Diterima', + message: 'Jadwal telekonsultasi dengan $name telah disetujui & slot terkunci.', + type: ToastType.success, + ); + }, + ), + ), + ], + ), + ], + ), + ); + }, + ), + ), + if (_pendingBookings.length > 1) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(_pendingBookings.length, (dotIdx) { + final isActive = dotIdx == _currentBookingIndex; + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: isActive ? 16 : 6, + height: 5, + decoration: BoxDecoration( + color: isActive ? const Color(0xFFD97706) : const Color(0xFFE2E4EB), + borderRadius: BorderRadius.circular(4), + ), + ); + }), + ), + ], + const SizedBox(height: 18), + ], + + // 5. HORIZONTAL SCHEDULE MINI-CALENDAR STRIP (LENGKAP 7 HARI, MERAH JIKA LIBUR) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Jadwal Praktik Mingguan', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), + Text( + 'November 2026', + style: AppTypography.bodySmall.copyWith(fontSize: 11, fontWeight: FontWeight.w600), + ), + ], + ), + const SizedBox(height: 10), + SizedBox( + height: 74, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _calendarDays.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, idx) { + final item = _calendarDays[idx]; + final isSelected = _selectedCalendarIndex == idx; + final isToday = item['isToday'] == true; + final isOffDay = item['isOffDay'] == true; + + return GestureDetector( + onTap: () => setState(() => _selectedCalendarIndex = idx), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: 52, + padding: const EdgeInsets.symmetric(vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? (isOffDay ? AppColors.highRiskRed : AppColors.primary) + : (isOffDay ? AppColors.highRiskBg : Colors.white), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? (isOffDay ? AppColors.highRiskRed : AppColors.primary) + : (isOffDay + ? AppColors.highRiskRed.withValues(alpha: 0.3) + : (isToday ? AppColors.primary.withValues(alpha: 0.4) : const Color(0xFFECEFF8))), + width: (isToday || isOffDay) ? 1.4 : 1, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: (isOffDay ? AppColors.highRiskRed : AppColors.primary).withValues(alpha: 0.25), + blurRadius: 10, + offset: const Offset(0, 3), + ) + ] + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item['day'] as String, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isSelected + ? Colors.white70 + : (isOffDay ? AppColors.highRiskRed : AppColors.textSecondary), + ), + ), + const SizedBox(height: 2), + Text( + item['date'] as String, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: isSelected + ? Colors.white + : (isOffDay ? AppColors.highRiskRed : AppColors.textPrimary), + ), + ), + const SizedBox(height: 3), + if (isOffDay) + Text( + 'Libur', + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.bold, + color: isSelected ? Colors.white : AppColors.highRiskRed, + ), + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (item['hasSession'] == true) + Container( + width: 4, + height: 4, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: isSelected ? Colors.white : AppColors.primary, + shape: BoxShape.circle, + ), + ), + if (item['isHighRisk'] == true) + Container( + width: 4, + height: 4, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: const BoxDecoration( + color: AppColors.highRiskRed, + shape: BoxShape.circle, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 12), + + // 6. DETAIL SLOT JADWAL HARIAN (INTERAKTIF & BISA KLIK MENUJU REKAM PASIEN) + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Sesi ${selectedDayData['day']}, ${selectedDayData['date']} Nov', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + Text( + isSelectedDayOff ? 'Libur Praktik' : '${selectedSessions.length} Agenda', + style: TextStyle( + fontSize: 11, + fontWeight: isSelectedDayOff ? FontWeight.bold : FontWeight.normal, + color: isSelectedDayOff ? AppColors.highRiskRed : AppColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 10), + if (isSelectedDayOff) ...[ + // TAMPILAN JADWAL LIBUR (OFFLINE) + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.highRiskBg.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.highRiskRed.withValues(alpha: 0.2)), + ), + child: Row( + children: const [ + Icon(Icons.event_busy_rounded, color: AppColors.highRiskRed, size: 24), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hari Libur Praktik (Offline)', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: AppColors.highRiskRed), + ), + SizedBox(height: 2), + Text( + 'Semua slot konsultasi ditutup pada hari ini. Pasien tidak dapat melakukan reservasi baru.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + ], + ), + ), + ], + ), + ), + ] else if (selectedSessions.isEmpty) ...[ + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Text( + 'Tidak ada jadwal konsultasi pada hari ini.', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + ), + ), + ] else ...[ + ...selectedSessions.map((session) { + final isLive = session['isLive'] == true; + final isRisk = session['isHighRisk'] == true; + + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + if (session['patientId'] != null) { + Navigator.of(context).pushNamed( + '/counselor-patient-detail', + arguments: session['patientId'], + ); + } + }, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isLive + ? AppColors.primaryContainer.withValues(alpha: 0.35) + : (isRisk + ? AppColors.highRiskBg.withValues(alpha: 0.6) + : const Color(0xFFF7F8FC)), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isLive + ? AppColors.primary.withValues(alpha: 0.3) + : (isRisk ? AppColors.highRiskRed.withValues(alpha: 0.2) : const Color(0xFFECEFF8)), + ), + ), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session['time'] as String, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 11, color: AppColors.textPrimary), + ), + const SizedBox(height: 2), + Text( + session['status'] as String, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: isLive + ? AppColors.primary + : (isRisk ? AppColors.highRiskRed : AppColors.textSecondary), + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session['patientName'] as String, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 1), + Text( + session['chiefComplaint'] as String, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 10, color: AppColors.textSecondary), + ), + ], + ), + ), + if (isLive) + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: () { + Navigator.of(context).pushNamed( + '/counselor-video-call', + arguments: 'session-live-001', + ); + }, + child: const Text('Buka VC', style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), + ) + else if (session['patientId'] != null) + const Icon(Icons.chevron_right_rounded, color: AppColors.textLight, size: 20), + ], + ), + ), + ); + }).toList(), + ], + ], + ), + ), + const SizedBox(height: 18), + + // 7. 2x2 METRIC CARDS (Clean, Soft Borders) + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.people_outline_rounded, color: AppColors.primary, size: 18), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: AppColors.verifiedBg, + borderRadius: BorderRadius.circular(6), + ), + child: const Text('+2', style: TextStyle(color: AppColors.verifiedGreen, fontSize: 10, fontWeight: FontWeight.bold)), + ), + ], + ), + const SizedBox(height: 10), + const Text('14', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), + const SizedBox(height: 2), + const Text('Pasien Aktif', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + ], + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.secondary, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.check_circle_outline_rounded, color: AppColors.primary, size: 18), + ), + const Text('Bulan ini', style: TextStyle(fontSize: 10, color: AppColors.textSecondary)), + ], + ), + const SizedBox(height: 10), + const Text('38', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), + const SizedBox(height: 2), + const Text('Sesi Selesai', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () { + ref.read(counselorBottomNavIndexProvider.notifier).state = 2; + }, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.tertiary, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.account_balance_wallet_outlined, color: AppColors.primary, size: 18), + ), + const Text('Tarik', style: TextStyle(color: AppColors.primary, fontSize: 11, fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 10), + const Text( + 'Rp 4.250.000', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: AppColors.textPrimary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + const Text('Saldo Praktik', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + ], + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: GestureDetector( + onTap: () { + Navigator.of(context).pushNamed('/counselor-patient-detail', arguments: 'patient-002'); + }, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.highRiskBg, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: AppColors.highRiskRed.withValues(alpha: 0.15)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.crisis_alert_rounded, color: AppColors.highRiskRed, size: 18), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + ), + child: const Text('Perhatian', style: TextStyle(color: AppColors.highRiskRed, fontSize: 9, fontWeight: FontWeight.bold)), + ), + ], + ), + const SizedBox(height: 10), + const Text('1 Pasien', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.highRiskRed)), + const SizedBox(height: 2), + const Text('Peringatan Berisiko', style: TextStyle(fontSize: 11, color: AppColors.highRiskRed)), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 18), + + // 8. RECENT PATIENTS LIST + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Daftar Pasien Konseling', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), + GestureDetector( + onTap: () { + ref.read(counselorBottomNavIndexProvider.notifier).state = 1; + }, + child: const Text('Lihat Semua', style: TextStyle(fontSize: 12, color: AppColors.primary, fontWeight: FontWeight.w600)), + ), + ], + ), + const SizedBox(height: 10), + patientsAsync.when( + data: (patients) { + return Column( + children: patients.take(3).map((p) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: InkWell( + onTap: () { + Navigator.of(context).pushNamed( + '/counselor-patient-detail', + arguments: p.id, + ); + }, + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: p.isHighRisk + ? AppColors.highRiskBg + : AppColors.primaryContainer, + child: Text( + p.name.substring(0, 1), + style: TextStyle( + fontWeight: FontWeight.w700, + color: p.isHighRisk + ? AppColors.highRiskRed + : AppColors.primary, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 2), + Text( + p.chiefComplaint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + ], + ), + ), + const SizedBox(width: 8), + p.hasConsent + ? StatusBadge.consentGranted('Akses Ada') + : StatusBadge.consentLocked('Terkunci'), + ], + ), + ), + ); + }).toList(), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error: $e'), + ), + const SizedBox(height: 80), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_main_shell_screen.dart b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_main_shell_screen.dart new file mode 100644 index 0000000..469a501 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_main_shell_screen.dart @@ -0,0 +1,2081 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../core/widgets/top_toast.dart'; +import '../../../auth/presentation/providers/auth_provider.dart'; +import '../../../finances/presentation/screens/transaction_history_screen.dart'; +import '../../../patients/presentation/screens/patient_list_screen.dart'; +import '../providers/counselor_nav_provider.dart'; +import 'counselor_home_screen.dart'; + +class CounselorMainShellScreen extends ConsumerStatefulWidget { + const CounselorMainShellScreen({super.key}); + + @override + ConsumerState createState() => + _CounselorMainShellScreenState(); +} + +class _CounselorMainShellScreenState + extends ConsumerState { + final List _pages = const [ + CounselorHomeScreen(), + PatientListScreen(), + TransactionHistoryScreen(), + _CounselorProfileTab(), + ]; + + @override + Widget build(BuildContext context) { + final currentIndex = ref.watch(counselorBottomNavIndexProvider); + + return Scaffold( + extendBody: true, + body: IndexedStack( + index: currentIndex, + children: _pages, + ), + bottomNavigationBar: Container( + margin: const EdgeInsets.only(left: 20, right: 20, bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + boxShadow: const [ + BoxShadow( + color: AppColors.glassShadow, + blurRadius: 24, + offset: Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(28), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + height: 68, + decoration: BoxDecoration( + color: AppColors.glassBackground, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: AppColors.glassBorder, width: 1.2), + ), + child: Row( + children: [ + Expanded(child: _buildNavItem(0, currentIndex, Icons.home_rounded, Icons.home_outlined, 'Beranda')), + Expanded(child: _buildNavItem(1, currentIndex, Icons.people_alt_rounded, Icons.people_alt_outlined, 'Pasien')), + Expanded(child: _buildNavItem(2, currentIndex, Icons.account_balance_wallet_rounded, Icons.account_balance_wallet_outlined, 'Keuangan')), + Expanded(child: _buildNavItem(3, currentIndex, Icons.person_rounded, Icons.person_outline, 'Profil')), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildNavItem(int index, int currentIndex, IconData activeIcon, IconData inactiveIcon, String label) { + final isSelected = currentIndex == index; + return GestureDetector( + onTap: () => ref.read(counselorBottomNavIndexProvider.notifier).state = index, + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryContainer : Colors.transparent, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + isSelected ? activeIcon : inactiveIcon, + color: isSelected ? AppColors.primary : AppColors.textSecondary, + size: 22, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: AppTypography.bodySmall.copyWith( + fontSize: 10, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? AppColors.primary : AppColors.textSecondary, + ), + ), + ], + ), + ); + } +} + +class _CounselorProfileTab extends ConsumerStatefulWidget { + const _CounselorProfileTab(); + + @override + ConsumerState<_CounselorProfileTab> createState() => _CounselorProfileTabState(); +} + +class _CounselorProfileTabState extends ConsumerState<_CounselorProfileTab> { + final List _days = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; + final Set _activeDays = {'Sen', 'Sel', 'Rab', 'Kam', 'Jum'}; + TimeOfDay _startTime = const TimeOfDay(hour: 9, minute: 0); + TimeOfDay _endTime = const TimeOfDay(hour: 17, minute: 0); + String _workingHours = '09:00 - 17:00 WIB'; + bool _isVacationMode = false; + int _sessionDuration = 50; + int _bufferTime = 15; + int _consultationRate = 200000; + bool _requireManualApproval = true; // Booking confirmation mode + + String _formatTimeOfDay(TimeOfDay tod) { + final h = tod.hour.toString().padLeft(2, '0'); + final m = tod.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } + + String _formatNumber(int n) { + return n.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]}.', + ); + } + + // 1. MANUAL WORKING HOURS PICKER (DENGAN showTimePicker) + Future _showEditWorkingHoursModal() async { + TimeOfDay tempStart = _startTime; + TimeOfDay tempEnd = _endTime; + + await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModalState) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Ubah Jam Operasional Praktik', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Pilih jam mulai dan jam selesai praktik secara manual dengan tombol di bawah:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 18), + + // Manual Time Picker Cards + Row( + children: [ + // JAM MULAI + Expanded( + child: InkWell( + onTap: () async { + final picked = await showTimePicker( + context: ctx, + initialTime: tempStart, + 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!, + ); + }, + ); + if (picked != null) { + setModalState(() => tempStart = picked); + } + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Jam Mulai Praktik', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.access_time_rounded, size: 16, color: AppColors.primary), + const SizedBox(width: 6), + Text( + '${_formatTimeOfDay(tempStart)} WIB', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ], + ), + ], + ), + ), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.arrow_forward_rounded, color: AppColors.textLight, size: 18), + ), + // JAM SELESAI + Expanded( + child: InkWell( + onTap: () async { + final picked = await showTimePicker( + context: ctx, + initialTime: tempEnd, + 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!, + ); + }, + ); + if (picked != null) { + setModalState(() => tempEnd = picked); + } + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Jam Selesai Praktik', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.access_time_filled_rounded, size: 16, color: AppColors.primary), + const SizedBox(width: 6), + Text( + '${_formatTimeOfDay(tempEnd)} WIB', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Quick Presets + const Text('Atau Pilih Preset Cepat:', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildPresetTimeChip('09:00 - 17:00 (Standar)', () { + setModalState(() { + tempStart = const TimeOfDay(hour: 9, minute: 0); + tempEnd = const TimeOfDay(hour: 17, minute: 0); + }); + }), + _buildPresetTimeChip('08:00 - 16:00 (Pagi)', () { + setModalState(() { + tempStart = const TimeOfDay(hour: 8, minute: 0); + tempEnd = const TimeOfDay(hour: 16, minute: 0); + }); + }), + _buildPresetTimeChip('13:00 - 21:00 (Sore)', () { + setModalState(() { + tempStart = const TimeOfDay(hour: 13, minute: 0); + tempEnd = const TimeOfDay(hour: 21, minute: 0); + }); + }), + _buildPresetTimeChip('18:00 - 22:00 (Malam)', () { + setModalState(() { + tempStart = const TimeOfDay(hour: 18, minute: 0); + tempEnd = const TimeOfDay(hour: 22, minute: 0); + }); + }), + ], + ), + const SizedBox(height: 20), + + // Save Button + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(46), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + onPressed: () { + setState(() { + _startTime = tempStart; + _endTime = tempEnd; + _workingHours = '${_formatTimeOfDay(_startTime)} - ${_formatTimeOfDay(_endTime)} WIB'; + }); + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Jam Praktik Diperbarui', + message: 'Jam operasional berhasil disetel ke $_workingHours', + type: ToastType.success, + ); + }, + child: const Text('Simpan Jam Operasional', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ); + } + + Widget _buildPresetTimeChip(String label, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFF0F3FA), + borderRadius: BorderRadius.circular(8), + ), + child: Text(label, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + ), + ); + } + + // 2. PATIENT REVIEWS MODAL + void _showPatientReviewsModal() { + final reviews = [ + { + 'name': 'R**** P****', + 'topic': 'Konseling Kecemasan & Insomnia', + 'rating': 5, + 'date': '2 hari lalu', + 'comment': 'dr. Sarah sangat mendengarkan tanpa menghakimi. Teknik regulasi napas dan CBT yang diajarkan sangat membantu mengendalikan serangan panik saya. Terima kasih banyak!', + 'tag': 'Sangat Empatik', + }, + { + 'name': 'M**** S****', + 'topic': 'Burnout Akademik & Skripsi', + 'rating': 5, + 'date': '5 hari lalu', + 'comment': 'Sesi konsultasi sangat terstruktur dan menenangkan. Saya mendapat sudut pandang baru dan langkah tindak lanjut yang sangat realistis untuk dijalankan.', + 'tag': 'Solutif & Tenang', + }, + { + 'name': 'A**** N****', + 'topic': 'Regulasi Emosi & Hubungan', + 'rating': 5, + 'date': '1 minggu lalu', + 'comment': 'Dokter sangat ramah, hangat, dan memberikan insight mendalam tentang pola komunikasi saya dengan keluarga.', + 'tag': 'Insight Mendalam', + }, + { + 'name': 'D**** K****', + 'topic': 'Stres Kerja & Manajemen Waktu', + 'rating': 4, + 'date': '2 minggu lalu', + 'comment': 'Sangat membantu memetakan prioritas hidup. Telekonsultasi tepat waktu dan rangkuman SOAP Luna sangat memudahkan saya mereview kembali.', + 'tag': 'Tepat Waktu', + }, + ]; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => DraggableScrollableSheet( + initialChildSize: 0.8, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (_, scrollController) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ListView( + controller: scrollController, + children: [ + const SizedBox(height: 12), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E4EB), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Ulasan & Penilaian Pasien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 12), + + // Overall Score Card + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + children: [ + Column( + children: const [ + Text('4.9', style: TextStyle(fontSize: 34, fontWeight: FontWeight.w800, color: AppColors.textPrimary)), + Row( + children: [ + Icon(Icons.star_rounded, color: AppColors.warning, size: 16), + Icon(Icons.star_rounded, color: AppColors.warning, size: 16), + Icon(Icons.star_rounded, color: AppColors.warning, size: 16), + Icon(Icons.star_rounded, color: AppColors.warning, size: 16), + Icon(Icons.star_rounded, color: AppColors.warning, size: 16), + ], + ), + SizedBox(height: 2), + Text('140 Ulasan', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + ], + ), + const SizedBox(width: 24), + Expanded( + child: Column( + children: [ + _buildRatingBar('5 ⭐', 0.92, '130'), + const SizedBox(height: 4), + _buildRatingBar('4 ⭐', 0.06, '8'), + const SizedBox(height: 4), + _buildRatingBar('3 ⭐', 0.02, '2'), + const SizedBox(height: 4), + _buildRatingBar('2 ⭐', 0.0, '0'), + const SizedBox(height: 4), + _buildRatingBar('1 ⭐', 0.0, '0'), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 14), + + // Highlight Tags (Wrap agar tidak overflow) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildReviewTagPill('â¤ī¸ Sangat Empatik (98%)'), + _buildReviewTagPill('💡 Solutif (95%)'), + _buildReviewTagPill('âąī¸ Tepat Waktu (99%)'), + ], + ), + const SizedBox(height: 18), + const Text('Testimoni Pasien (Anonim demi Privasi):', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 10), + + ...reviews.map((r) { + 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(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + CircleAvatar( + radius: 14, + backgroundColor: AppColors.primaryContainer, + child: const Icon(Icons.person, size: 16, color: AppColors.primary), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(r['name'] as String, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + Text(r['topic'] as String, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary)), + ], + ), + ], + ), + Text(r['date'] as String, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary)), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + ...List.generate( + r['rating'] as int, + (_) => const Icon(Icons.star_rounded, color: AppColors.warning, size: 14), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.secondary, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + r['tag'] as String, + style: const TextStyle(color: AppColors.primary, fontSize: 9, fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + r['comment'] as String, + style: const TextStyle(fontSize: 11, height: 1.4, color: AppColors.textPrimary), + ), + ], + ), + ); + }).toList(), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } + + Widget _buildRatingBar(String label, double pct, String count) { + return Row( + children: [ + SizedBox(width: 26, child: Text(label, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary))), + const SizedBox(width: 6), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: pct, + backgroundColor: const Color(0xFFE5E9F2), + valueColor: const AlwaysStoppedAnimation(AppColors.warning), + minHeight: 6, + ), + ), + ), + const SizedBox(width: 6), + SizedBox(width: 22, child: Text(count, textAlign: TextAlign.right, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary))), + ], + ); + } + + Widget _buildReviewTagPill(String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFF0F3FA), + borderRadius: BorderRadius.circular(8), + ), + child: Text(label, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + ); + } + + // 3. EDIT RATE MODAL (BISA MANUAL INPUT & PILIH REKOMENDASI) + void _showEditRateModal() { + int selectedRate = _consultationRate; + final rateCtrl = TextEditingController(text: selectedRate.toString()); + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModalState) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Ubah Tarif Konsultasi', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + Text( + 'Tentukan tarif per sesi ($_sessionDuration menit). Anda dapat mengetik manual atau memilih rekomendasi:', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + + // INPUT MANUAL TARIF DENGAN KEYBOARD ANGKA + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.35), width: 1.4), + ), + child: Row( + children: [ + const Text('Rp', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: AppColors.primary)), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: rateCtrl, + keyboardType: TextInputType.number, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 19, color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: '0', + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 8), + ), + onChanged: (val) { + final parsed = int.tryParse(val.replaceAll(RegExp(r'[^0-9]'), '')); + if (parsed != null && parsed >= 0) { + setModalState(() => selectedRate = parsed); + } + }, + ), + ), + Text('/ $_sessionDuration Mnt', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), + ], + ), + ), + const SizedBox(height: 14), + + const Text('Rekomendasi Tarif Populer:', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), + const SizedBox(height: 8), + + // Rate Option Chips + Wrap( + spacing: 8, + runSpacing: 8, + children: [150000, 200000, 250000, 300000, 350000].map((rate) { + final isSelected = selectedRate == rate; + return GestureDetector( + onTap: () { + setModalState(() { + selectedRate = rate; + rateCtrl.text = rate.toString(); + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFECEFF8), + width: 1.2, + ), + ), + child: Text( + 'Rp ${_formatNumber(rate)}${rate == 200000 ? " (HIMPSI)" : ""}', + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.w600, + fontSize: 11, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + + // Breakdown Honorarium + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Honorarium Bersih Psikolog (85%):', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + Text('Rp ${_formatNumber((selectedRate * 0.85).toInt())}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.verifiedGreen)), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Operasional Luna & Enkripsi Medis (15%):', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + Text('Rp ${_formatNumber((selectedRate * 0.15).toInt())}', style: const TextStyle(fontSize: 11, color: AppColors.textSecondary)), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(46), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + onPressed: () { + setState(() => _consultationRate = selectedRate); + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Tarif Konsultasi Disimpan', + message: 'Tarif berhasil diatur ke Rp ${_formatNumber(_consultationRate)} / $_sessionDuration menit.', + type: ToastType.success, + ); + }, + child: const Text('Simpan Tarif Baru', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ); + } + + // 4. EDIT DURATION & BUFFER MODAL (DURASI & BUFFER BEBAS PILIH / MANUAL STEPPER) + void _showEditDurationAndBufferModal() { + int tempDuration = _sessionDuration; + int tempBuffer = _bufferTime; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModalState) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Durasi Sesi & Jeda Istirahat', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Tentukan durasi panggilan konsultasi dan waktu istirahat antar pasien secara fleksibel:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + + // DURASI SESI DENGAN COUNTER STEPPER MANUAL & CHIPS + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Durasi Konseling:', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12)), + Container( + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove, size: 16, color: AppColors.primary), + visualDensity: VisualDensity.compact, + onPressed: () { + if (tempDuration > 15) { + setModalState(() => tempDuration -= 5); + } + }, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '$tempDuration Menit', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: AppColors.primary), + ), + ), + IconButton( + icon: const Icon(Icons.add, size: 16, color: AppColors.primary), + visualDensity: VisualDensity.compact, + onPressed: () { + if (tempDuration < 180) { + setModalState(() => tempDuration += 5); + } + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [30, 45, 50, 60, 75, 90].map((dur) { + final isSelected = tempDuration == dur; + return GestureDetector( + onTap: () => setModalState(() => tempDuration = dur), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFECEFF8), + width: 1.2, + ), + ), + child: Text( + '$dur Mnt${dur == 50 ? " (Standar)" : ""}', + style: TextStyle( + color: isSelected ? Colors.white : AppColors.textPrimary, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w600, + fontSize: 11, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 18), + + // JEDA BUFFER DENGAN COUNTER STEPPER MANUAL & CHIPS + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Jeda Istirahat (Buffer Time):', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12)), + Container( + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove, size: 16, color: AppColors.primary), + visualDensity: VisualDensity.compact, + onPressed: () { + if (tempBuffer > 0) { + setModalState(() => tempBuffer -= 5); + } + }, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '$tempBuffer Menit', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: AppColors.primary), + ), + ), + IconButton( + icon: const Icon(Icons.add, size: 16, color: AppColors.primary), + visualDensity: VisualDensity.compact, + onPressed: () { + if (tempBuffer < 60) { + setModalState(() => tempBuffer += 5); + } + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [0, 5, 10, 15, 20, 30].map((buf) { + final isSelected = tempBuffer == buf; + return GestureDetector( + onTap: () => setModalState(() => tempBuffer = buf), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFECEFF8), + width: 1.2, + ), + ), + child: Text( + buf == 0 ? 'Tanpa Jeda' : '$buf Mnt${buf == 15 ? " (Disarankan)" : ""}', + style: TextStyle( + color: isSelected ? Colors.white : AppColors.textPrimary, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w600, + fontSize: 11, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + const Icon(Icons.info_outline_rounded, size: 16, color: AppColors.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Total slot waktu: ${tempDuration + tempBuffer} menit ($tempDuration mnt telekonseling + $tempBuffer mnt review SOAP).', + style: const TextStyle(fontSize: 11, color: AppColors.textPrimary), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(46), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + onPressed: () { + setState(() { + _sessionDuration = tempDuration; + _bufferTime = tempBuffer; + }); + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Durasi Sesi Diperbarui', + message: 'Sesi ditetapkan ke $_sessionDuration menit dengan jeda istirahat $_bufferTime menit.', + type: ToastType.success, + ); + }, + child: const Text('Simpan Durasi Sesi', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ); + } + + // 5. BANK ACCOUNT MODAL + void _showBankAccountModal() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Rekening Bank Pencairan', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Rekening terdaftar untuk pencairan otomatis honorarium praktik Anda:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + + // Card Bank + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF1E3A8A), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1E3A8A).withValues(alpha: 0.25), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'BCA (Bank Central Asia)', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Text('UTAMA', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)), + ), + ], + ), + const SizedBox(height: 18), + const Text( + 'â€ĸâ€ĸâ€ĸâ€ĸ â€ĸâ€ĸâ€ĸâ€ĸ â€ĸâ€ĸâ€ĸâ€ĸ 8192', + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 2), + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text('dr. Sarah Jenkins, M.Psi', style: TextStyle(color: Colors.white70, fontSize: 12)), + Text('TERVERIFIKASI ✅', style: TextStyle(color: Color(0xFF6EE7B7), fontSize: 10, fontWeight: FontWeight.bold)), + ], + ), + ], + ), + ), + const SizedBox(height: 14), + + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + children: const [ + Icon(Icons.schedule_rounded, size: 16, color: AppColors.primary), + SizedBox(width: 8), + Expanded( + child: Text( + 'Pencairan dana otomatis diproses setiap hari Jumat pukul 15:00 WIB ke rekening di atas tanpa potongan biaya transfer.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.3), + ), + ), + ], + ), + ), + const SizedBox(height: 18), + + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFD6D9E4)), + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.edit_note_rounded, size: 18, color: AppColors.primary), + label: const Text('Ubah Rekening Bank', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.primary)), + onPressed: () { + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Verifikasi Rekening', + message: 'Permintaan perubahan rekening bank memerlukan verifikasi KTP & SIPP (1x24 jam).', + type: ToastType.info, + ); + }, + ), + const SizedBox(height: 10), + ], + ), + ), + ); + } + + // 6. STR & SIPP CREDENTIALS MODAL + void _showStrCredentialsModal() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Kredensial SIPP & STR Psikolog', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Kredensial klinis resmi Anda yang terverifikasi di Kementerian Kesehatan & HIMPSI:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.verifiedBg, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.verifiedGreen.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text('Surat Tanda Registrasi (STR)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.verifiedGreen)), + Icon(Icons.verified_rounded, color: AppColors.verifiedGreen, size: 18), + ], + ), + const SizedBox(height: 6), + const Text('Nomor: 4192-PSI-2024', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 2), + const Text('Penerbit: KTKI (Konsil Tenaga Kesehatan Indonesia)', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + const SizedBox(height: 2), + const Text('Masa Berlaku: 18 Oktober 2029 (Aktif)', style: TextStyle(fontSize: 11, color: AppColors.verifiedGreen, fontWeight: FontWeight.w600)), + ], + ), + ), + const SizedBox(height: 10), + + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('Surat Izin Praktik Psikologi (SIPP)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.primary)), + SizedBox(height: 6), + Text('Nomor: 503/SIPP/DKS/2023', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + SizedBox(height: 2), + Text('Wilayah Layanan: Nasional (Telekonsultasi)', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + SizedBox(height: 2), + Text('Keanggotaan HIMPSI: Aktif (#HM-9214)', style: TextStyle(fontSize: 11, color: AppColors.textPrimary, fontWeight: FontWeight.w600)), + ], + ), + ), + const SizedBox(height: 18), + + OutlinedButton( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFD6D9E4)), + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () { + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Perpanjangan Kredensial', + message: 'Upload pembaruan STR/SIPP melalui portal web admin psikolog.', + type: ToastType.info, + ); + }, + child: const Text('Perbarui Dokumen Kredensial', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.primary)), + ), + const SizedBox(height: 10), + ], + ), + ), + ); + } + + // 7. SECURITY & PRIVACY MODAL + void _showSecuritySettingsModal() { + bool biometric = true; + bool twoFactor = true; + bool maskPatient = true; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModalState) => Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Keamanan & Privasi Klinis', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Perlindungan data pasien sesuai standar kerahasiaan medis & HIPAA:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 14), + + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Kunci Biometrik (Face ID / Sidik Jari)', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), + subtitle: const Text('Wajib autentikasi sebelum membuka rekam medis pasien', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + value: biometric, + activeColor: AppColors.primary, + onChanged: (val) => setModalState(() => biometric = val), + ), + const Divider(height: 1, color: Color(0xFFF0F2F8)), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Verifikasi 2 Langkah (2FA)', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), + subtitle: const Text('Kode OTP saat login di perangkat baru', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + value: twoFactor, + activeColor: AppColors.primary, + onChanged: (val) => setModalState(() => twoFactor = val), + ), + const Divider(height: 1, color: Color(0xFFF0F2F8)), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Masking Nama Pasien di Notifikasi', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), + subtitle: const Text('Sembunyikan nama terang pasien pada notifikasi layar kunci', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + value: maskPatient, + activeColor: AppColors.primary, + onChanged: (val) => setModalState(() => maskPatient = val), + ), + const SizedBox(height: 18), + + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + onPressed: () { + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Keamanan Diperbarui', + message: 'Pengaturan keamanan rekam medis berhasil disimpan.', + type: ToastType.success, + ); + }, + child: const Text('Simpan Pengaturan', style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 10), + ], + ), + ), + ), + ); + } + + // 8. ETHICAL GUIDELINES MODAL + void _showEthicalGuidelinesModal() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Panduan Etik & SOP HIMPSI', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 8), + const Text( + 'Standar Operasional Prosedur Layanan Telekonseling Luna:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 14), + _buildEthicItem('1. Kerahasiaan & Batas Etik', 'Data rekam medis SOAP terenkripsi end-to-end dan hanya dapat diakses psikolog penanggung jawab.'), + const SizedBox(height: 8), + _buildEthicItem('2. Protokol De-eskalasi Krisis', 'Jika skor DASS-21 atau deteksi Luna AI menunjukkan risiko self-harm, psikolog wajib mengaktifkan hotline darurat.'), + const SizedBox(height: 8), + _buildEthicItem('3. Dokumentasi SOAP Tepat Waktu', 'Psikolog diharapkan mereview dan memverifikasi draft auto-SOAP Luna maksimal 24 jam setelah sesi selesai.'), + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + onPressed: () => Navigator.pop(ctx), + child: const Text('Tutup', style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 10), + ], + ), + ), + ); + } + + Widget _buildEthicItem(String title, String desc) { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(desc, style: const TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.3)), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authStateProvider); + final user = authState.value; + + return Scaffold( + backgroundColor: const Color(0xFFF8F9FE), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Profile Banner Card (Modern Health App Styling) + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.04), + blurRadius: 18, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + Row( + children: [ + CircleAvatar( + radius: 34, + backgroundColor: AppColors.primaryContainer, + backgroundImage: user?.profilePhotoUrl != null + ? NetworkImage(user!.profilePhotoUrl!) + : null, + child: user?.profilePhotoUrl == null + ? const Icon(Icons.person, size: 34, color: AppColors.primary) + : null, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user?.displayName ?? 'dr. Sarah Jenkins, M.Psi', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 2), + Text( + user?.email ?? 'sarah.jenkins@psikolog.eva.id', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 6), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.verifiedBg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'STR: ${user?.strNumber ?? "4192-PSI-2024"}', + style: const TextStyle( + color: AppColors.verifiedGreen, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + + // Stats Bar (Rating, Sessions, Response Rate) - BISA DIKLIK MENUJU ULASAN! + Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + InkWell( + onTap: _showPatientReviewsModal, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: _buildStatItem('⭐ 4.9', '140 Ulasan >'), + ), + ), + Container(width: 1, height: 28, color: const Color(0xFFE2E5F0)), + _buildStatItem('38', 'Sesi Bulan Ini'), + Container(width: 1, height: 28, color: const Color(0xFFE2E5F0)), + _buildStatItem('100%', 'Respon Booking'), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // CARD REVIW & KEPUASAN PASIEN + InkWell( + onTap: _showPatientReviewsModal, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.secondary, + borderRadius: BorderRadius.circular(14), + ), + child: const Icon(Icons.star_rounded, color: AppColors.primary, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('Kepuasan Pasien â€ĸ 4.9 / 5.0', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + SizedBox(height: 2), + Text('Lihat rincian 140 ulasan & testimoni konseling Anda', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + ], + ), + ), + const Icon(Icons.chevron_right_rounded, color: AppColors.textLight, size: 22), + ], + ), + ), + ), + const SizedBox(height: 20), + + // 2. SISTEM PENERIMAAN BOOKING PASIEN (MANUAL CONFIRM VS AUTO) + const Text('Sistem Penerimaan Booking Pasien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 4), + const Text( + 'Tentukan bagaimana reservasi jadwal konsultasi baru dari pasien diproses oleh sistem.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + const SizedBox(height: 10), + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + children: [ + // Opsi 1: Menunggu Persetujuan Psikolog + InkWell( + onTap: () => setState(() => _requireManualApproval = true), + borderRadius: BorderRadius.circular(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + _requireManualApproval ? Icons.radio_button_checked : Icons.radio_button_off, + color: _requireManualApproval ? AppColors.primary : AppColors.textLight, + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('Menunggu Persetujuan Psikolog (Disarankan)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + SizedBox(height: 2), + Text( + 'Setiap pasien booking, Anda mendapat notifikasi & berhak menyetujui atau mengajukan opsi jadwal lain.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.4), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 20, color: Color(0xFFF0F2F8)), + + // Opsi 2: Otomatis Diterima (Instan) + InkWell( + onTap: () => setState(() => _requireManualApproval = false), + borderRadius: BorderRadius.circular(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + !_requireManualApproval ? Icons.radio_button_checked : Icons.radio_button_off, + color: !_requireManualApproval ? AppColors.primary : AppColors.textLight, + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('Otomatis Diterima (Instan Booking)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + SizedBox(height: 2), + Text( + 'Slot konsultasi langsung terkunci otomatis jika pembayaran pasien telah terkonfirmasi.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.4), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + // 3. JADWAL PRAKTIK & OPERASIONAL + const Text('Hari & Jam Praktik Online', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 10), + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Hari Praktik Aktif:', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), + const SizedBox(height: 10), + + // HARI PRAKTIK AKTIF: CUSTOM DAY SELECTION PILLS (LEGA & BORDER LEMBUT, TIDAK DEMPET & TANPA GARIS TEGAS) + Wrap( + spacing: 10, + runSpacing: 10, + children: _days.map((day) { + final isActive = _activeDays.contains(day); + return GestureDetector( + onTap: () { + setState(() { + if (isActive) { + if (_activeDays.length > 1) _activeDays.remove(day); + } else { + _activeDays.add(day); + } + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: isActive ? AppColors.primary : const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isActive ? AppColors.primary : const Color(0xFFECEFF8), + width: 1.2, + ), + boxShadow: isActive + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.18), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isActive) ...[ + const Icon(Icons.check_rounded, color: Colors.white, size: 13), + const SizedBox(width: 4), + ], + Text( + day, + style: TextStyle( + color: isActive ? Colors.white : AppColors.textPrimary, + fontWeight: isActive ? FontWeight.bold : FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + const Divider(height: 24, color: Color(0xFFF0F2F8)), + + // Jam Praktik (Dapat Diedit Manual) + InkWell( + onTap: _showEditWorkingHoursModal, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: const [ + Icon(Icons.access_time_rounded, size: 16, color: AppColors.primary), + SizedBox(width: 6), + Text('Jam Operasional Praktik', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _workingHours, + style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 11), + ), + ), + const SizedBox(width: 6), + const Icon(Icons.edit_outlined, size: 15, color: AppColors.primary), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 14), + + // Durasi Sesi & Buffer Time (KEDUANYA BISA DIKLIK & DIEDIT) + Row( + children: [ + Expanded( + child: InkWell( + onTap: _showEditDurationAndBufferModal, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Durasi Sesi', style: TextStyle(fontSize: 10, color: AppColors.textSecondary)), + const SizedBox(height: 2), + Text('$_sessionDuration Menit', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + ], + ), + const Icon(Icons.tune_rounded, size: 14, color: AppColors.primary), + ], + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: InkWell( + onTap: _showEditDurationAndBufferModal, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Jeda Buffer', style: TextStyle(fontSize: 10, color: AppColors.textSecondary)), + const SizedBox(height: 2), + Text('$_bufferTime Menit', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + ], + ), + const Icon(Icons.tune_rounded, size: 14, color: AppColors.primary), + ], + ), + ), + ), + ), + ], + ), + const Divider(height: 24, color: Color(0xFFF0F2F8)), + + // Mode Libur (Vacation Mode) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('Mode Libur Sementara (Cuti)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + SizedBox(height: 2), + Text('Tutup sementara pemesanan slot jadwal baru', style: TextStyle(fontSize: 10, color: AppColors.textSecondary)), + ], + ), + Switch.adaptive( + value: _isVacationMode, + activeColor: AppColors.warning, + onChanged: (val) { + setState(() => _isVacationMode = val); + }, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + + // 4. HONORARIUM & TARIF (CLEAN MODERN CARDS) + const Text('Honorarium & Keuangan Praktik', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 10), + + _buildCleanMenuCard( + icon: Icons.payments_outlined, + iconColor: AppColors.verifiedGreen, + iconBg: AppColors.verifiedBg, + title: 'Tarif Konsultasi', + subtitle: 'Rp ${_formatNumber(_consultationRate)} per $_sessionDuration menit', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Rp ${_formatNumber(_consultationRate)}', + style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 11), + ), + const SizedBox(width: 4), + const Icon(Icons.edit_outlined, size: 12, color: AppColors.primary), + ], + ), + ), + onTap: _showEditRateModal, + ), + + _buildCleanMenuCard( + icon: Icons.account_balance_outlined, + iconColor: const Color(0xFF2563EB), + iconBg: const Color(0xFFEFF6FF), + title: 'Rekening Bank Pencairan', + subtitle: 'BCA â€ĸ ****8192 (dr. Sarah Jenkins)', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.verifiedBg, + borderRadius: BorderRadius.circular(8), + ), + child: const Text('Terverifikasi ✅', style: TextStyle(color: AppColors.verifiedGreen, fontWeight: FontWeight.bold, fontSize: 10)), + ), + onTap: _showBankAccountModal, + ), + + const SizedBox(height: 10), + + // 5. LEGALITAS & KEAMANAN KLINIS (CLEAN MODERN CARDS) + const Text('Legalitas & Keamanan Praktik', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 10), + + _buildCleanMenuCard( + icon: Icons.verified_user_outlined, + iconColor: AppColors.verifiedGreen, + iconBg: AppColors.verifiedBg, + title: 'Kredensial SIPP & STR Psikolog', + subtitle: 'STR #4192-PSI-2024 â€ĸ KTKI & HIMPSI', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.verifiedBg, + borderRadius: BorderRadius.circular(8), + ), + child: const Text('Aktif 2029', style: TextStyle(color: AppColors.verifiedGreen, fontWeight: FontWeight.bold, fontSize: 10)), + ), + onTap: _showStrCredentialsModal, + ), + + _buildCleanMenuCard( + icon: Icons.shield_outlined, + iconColor: AppColors.primary, + iconBg: AppColors.secondary, + title: 'Keamanan Rekam Medis & Privasi', + subtitle: 'Biometrik FaceID & Enkripsi AES-256', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: const Text('2FA Aktif', style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 10)), + ), + onTap: _showSecuritySettingsModal, + ), + + _buildCleanMenuCard( + icon: Icons.menu_book_outlined, + iconColor: const Color(0xFFD97706), + iconBg: const Color(0xFFFEF3C7), + title: 'Panduan Etik & SOP HIMPSI', + subtitle: 'Kode etik layanan telekonseling & penanganan krisis', + trailingBadge: const SizedBox.shrink(), + onTap: _showEthicalGuidelinesModal, + ), + const SizedBox(height: 24), + + // 6. LOGOUT + GestureDetector( + onTap: () async { + await ref.read(authStateProvider.notifier).logout(); + if (context.mounted) { + Navigator.of(context).pushReplacementNamed('/counselor-login'); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: AppColors.highRiskBg, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.logout, color: AppColors.error, size: 18), + SizedBox(width: 8), + Text( + 'Keluar dari Akun Konselor', + style: TextStyle(color: AppColors.error, fontWeight: FontWeight.bold, fontSize: 13), + ), + ], + ), + ), + ), + const SizedBox(height: 80), + ], + ), + ), + ), + ); + } + + Widget _buildCleanMenuCard({ + required IconData icon, + required Color iconColor, + required Color iconBg, + required String title, + required String subtitle, + required Widget trailingBadge, + required VoidCallback onTap, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.02), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: iconBg, + borderRadius: BorderRadius.circular(13), + ), + child: Icon(icon, color: iconColor, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(subtitle, style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), maxLines: 1, overflow: TextOverflow.ellipsis), + ], + ), + ), + const SizedBox(width: 8), + trailingBadge, + const SizedBox(width: 4), + const Icon(Icons.chevron_right_rounded, color: AppColors.textLight, size: 20), + ], + ), + ), + ), + ), + ); + } + + Widget _buildStatItem(String val, String label) { + return Column( + children: [ + Text(val, style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 14, color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(label, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary)), + ], + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_notification_screen.dart b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_notification_screen.dart new file mode 100644 index 0000000..d6866c3 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_notification_screen.dart @@ -0,0 +1,372 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; + +class CounselorNotificationScreen extends StatefulWidget { + const CounselorNotificationScreen({super.key}); + + @override + State createState() => _CounselorNotificationScreenState(); +} + +class _CounselorNotificationScreenState extends State { + String _selectedFilter = 'Semua'; + + final List> _notifications = [ + { + 'id': 'notif-1', + 'title': 'Peringatan Distres Akut Pasien', + 'body': 'Dinda Kirana terdeteksi krisis skor 9/10 (DASS-21: 28). Protokol de-eskalasi & kontak darurat disarankan untuk dievaluasi.', + 'category': 'Darurat', + 'time': '12 menit lalu', + 'isRead': false, + 'isCrisis': true, + 'patientId': 'patient-002', + 'actionLabel': 'Tinjau Pasien Segera', + }, + { + 'id': 'notif-2', + 'title': 'Permintaan Booking Sesi Baru', + 'body': 'Maya Saraswati (22 th) mengajukan jadwal telekonsultasi untuk besok Rabu, 12 Nov pukul 14:00 WIB. Mohon konfirmasi ketersediaan Anda.', + 'category': 'Booking', + 'time': '45 menit lalu', + 'isRead': false, + 'isBooking': true, + 'patientId': 'patient-004', + 'actionLabel': 'Konfirmasi Jadwal', + }, + { + 'id': 'notif-3', + 'title': 'Pengingat Sesi Mendatang', + 'body': 'Sesi telekonsultasi 1-on-1 dengan Rian Pratama akan dimulai dalam 15 menit (10:00 WIB). Ruang panggilan sudah siap.', + 'category': 'Jadwal', + 'time': '1 jam lalu', + 'isRead': false, + 'sessionId': 'session-live-001', + 'actionLabel': 'Masuk Sesi VC', + }, + { + 'id': 'notif-4', + 'title': 'Pencairan Dana Berhasil', + 'body': 'Honorarium sebesar Rp 2.000.000 berhasil ditransfer ke rekening BCA Anda (No. Rek: ****8192).', + 'category': 'Keuangan', + 'time': '1 hari lalu', + 'isRead': true, + 'actionLabel': 'Lihat Rincian', + }, + ]; + + @override + Widget build(BuildContext context) { + final filtered = _notifications.where((n) { + if (_selectedFilter == 'Semua') return true; + if (_selectedFilter == 'Darurat') return n['category'] == 'Darurat'; + if (_selectedFilter == 'Booking') return n['category'] == 'Booking'; + if (_selectedFilter == 'Jadwal') return n['category'] == 'Jadwal'; + return true; + }).toList(); + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + centerTitle: false, + title: Text('Notifikasi & Aktivitas', style: AppTypography.headingMedium), + actions: [ + TextButton( + onPressed: () { + setState(() { + for (var n in _notifications) { + n['isRead'] = true; + } + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Semua notifikasi ditandai sebagai dibaca.'), + backgroundColor: AppColors.primary, + ), + ); + }, + child: const Text( + 'Tandai Dibaca', + style: TextStyle(color: AppColors.primary, fontSize: 13, fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: 8), + ], + ), + body: SafeArea( + child: Column( + children: [ + // Filter Pills + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: ['Semua', 'Darurat', 'Booking', 'Jadwal'].map((cat) { + final isSel = _selectedFilter == cat; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => setState(() => _selectedFilter = cat), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), + decoration: BoxDecoration( + color: isSel ? AppColors.primary : AppColors.background, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + cat, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSel ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 10), + + // Notifications List + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + itemCount: filtered.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final notif = filtered[index]; + final isCrisis = notif['isCrisis'] == true; + final isBooking = notif['isBooking'] == true; + final isRead = notif['isRead'] == true; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isCrisis + ? AppColors.highRiskRed.withValues(alpha: 0.2) + : (isBooking + ? AppColors.primary.withValues(alpha: 0.15) + : const Color(0xFFF0F2F8)), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: isCrisis + ? AppColors.highRiskRed.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.02), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: Icon + Category + Time + Unread indicator + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isCrisis + ? AppColors.highRiskBg + : (isBooking ? AppColors.secondary : AppColors.primaryContainer), + shape: BoxShape.circle, + ), + child: Icon( + isCrisis + ? Icons.crisis_alert_rounded + : (isBooking + ? Icons.event_available_rounded + : Icons.notifications_active_rounded), + size: 18, + color: isCrisis ? AppColors.highRiskRed : AppColors.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + notif['category'] as String, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: isCrisis ? AppColors.highRiskRed : AppColors.primary, + ), + ), + const SizedBox(width: 6), + Text( + 'â€ĸ ${notif['time']}', + style: const TextStyle(fontSize: 11, color: AppColors.textLight), + ), + ], + ), + const SizedBox(height: 2), + Text( + notif['title'] as String, + style: AppTypography.headingSmall.copyWith( + fontSize: 14, + fontWeight: isRead ? FontWeight.w600 : FontWeight.bold, + ), + ), + ], + ), + ), + if (!isRead) + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Body + Text( + notif['body'] as String, + style: AppTypography.bodySmall.copyWith( + fontSize: 12, + color: AppColors.textPrimary.withValues(alpha: 0.85), + height: 1.5, + ), + ), + const SizedBox(height: 14), + + // Actions + Row( + children: [ + if (isBooking) ...[ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Jadwal booking berhasil disetujui & slot dikonfirmasi ke pasien.'), + backgroundColor: AppColors.verifiedGreen, + ), + ); + }, + child: const Text('Terima Booking', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFE0E3EC)), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Opsi reschedule telah dikirimkan ke pasien.'), + backgroundColor: AppColors.primary, + ), + ); + }, + child: const Text('Jadwal Lain', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), + ), + ), + ] else if (isCrisis) ...[ + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.highRiskRed, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.shield_outlined, size: 16), + label: Text(notif['actionLabel'] as String, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + onPressed: () { + Navigator.of(context).pushNamed( + '/counselor-patient-detail', + arguments: notif['patientId'], + ); + }, + ), + ), + ] else if (notif['sessionId'] != null) ...[ + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.videocam_rounded, size: 16), + label: Text(notif['actionLabel'] as String, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + onPressed: () { + Navigator.of(context).pushNamed( + '/counselor-video-call', + arguments: notif['sessionId'], + ); + }, + ), + ), + ] else ...[ + GestureDetector( + onTap: () { + Navigator.of(context).pushNamed('/counselor-finances'); + }, + child: Row( + children: [ + Text( + notif['actionLabel'] as String, + style: const TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_forward_rounded, color: AppColors.primary, size: 14), + ], + ), + ), + ], + ], + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/finances/data/datasources/finance_datasource.dart b/apps/eva_counselor_mobile/lib/features/finances/data/datasources/finance_datasource.dart new file mode 100644 index 0000000..398430c --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/finances/data/datasources/finance_datasource.dart @@ -0,0 +1,7 @@ +import '../models/transaction_dto.dart'; + +abstract class FinanceDataSource { + Future fetchSummary(); + Future> fetchTransactions(); + Future submitPayoutRequest(Map payload); +} diff --git a/apps/eva_counselor_mobile/lib/features/finances/data/datasources/mock_finance_datasource.dart b/apps/eva_counselor_mobile/lib/features/finances/data/datasources/mock_finance_datasource.dart new file mode 100644 index 0000000..443aec3 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/finances/data/datasources/mock_finance_datasource.dart @@ -0,0 +1,69 @@ +import '../models/transaction_dto.dart'; +import 'finance_datasource.dart'; + +class MockFinanceDataSource implements FinanceDataSource { + @override + Future fetchSummary() async { + await Future.delayed(const Duration(milliseconds: 300)); + return const FinancialSummaryResponseDto( + availableBalance: 2450000, + pendingClearingBalance: 400000, + totalEarningsThisMonth: 4850000, + totalWithdrawn: 2000000, + ); + } + + @override + Future> fetchTransactions() async { + await Future.delayed(const Duration(milliseconds: 400)); + return [ + TransactionResponseDto( + id: 'TRX-2026-0091', + patientName: 'Rian Pratama', + sessionType: 'Telekonsultasi Video (50 Menit)', + date: DateTime.now().subtract(const Duration(hours: 4)).toIso8601String(), + amount: 200000, + adminFee: 20000, + netEarnings: 180000, + status: 'completed', + ), + TransactionResponseDto( + id: 'TRX-2026-0084', + patientName: 'Dinda Kirana', + sessionType: 'Telekonsultasi Video Darurat (45 Menit)', + date: DateTime.now().subtract(const Duration(days: 1)).toIso8601String(), + amount: 250000, + adminFee: 25000, + netEarnings: 225000, + status: 'completed', + ), + TransactionResponseDto( + id: 'TRX-2026-0072', + patientName: 'Maya Lestari', + sessionType: 'Sesi Evaluasi Kognitif CBT (60 Menit)', + date: DateTime.now().subtract(const Duration(days: 3)).toIso8601String(), + amount: 200000, + adminFee: 20000, + netEarnings: 180000, + status: 'completed', + ), + TransactionResponseDto( + id: 'TRX-2026-0060', + patientName: 'Penarikan Dana (BCA ****8192)', + sessionType: 'Transfer Bank Pencairan Honorarium', + date: DateTime.now().subtract(const Duration(days: 7)).toIso8601String(), + amount: 2000000, + adminFee: 0, + netEarnings: 2000000, + status: 'payout_requested', + bankAccountInfo: 'BCA â€ĸ Sarah Anindya â€ĸ 8192039182', + ), + ]; + } + + @override + Future submitPayoutRequest(Map payload) async { + await Future.delayed(const Duration(milliseconds: 500)); + return true; + } +} diff --git a/apps/eva_counselor_mobile/lib/features/finances/data/datasources/remote_finance_datasource.dart b/apps/eva_counselor_mobile/lib/features/finances/data/datasources/remote_finance_datasource.dart new file mode 100644 index 0000000..6f7d571 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/finances/data/datasources/remote_finance_datasource.dart @@ -0,0 +1,30 @@ +import '../../../../core/network/api_client.dart'; +import '../../../../core/network/api_endpoints.dart'; +import '../models/transaction_dto.dart'; +import 'finance_datasource.dart'; + +class RemoteFinanceDataSource implements FinanceDataSource { + final ApiClient _client; + + RemoteFinanceDataSource({ApiClient? client}) : _client = client ?? ApiClient(); + + @override + Future fetchSummary() async { + final response = await _client.get(ApiEndpoints.financialSummary); + return FinancialSummaryResponseDto.fromJson(response as Map); + } + + @override + Future> fetchTransactions() async { + final response = await _client.get(ApiEndpoints.transactions); + return (response as List) + .map((t) => TransactionResponseDto.fromJson(t as Map)) + .toList(); + } + + @override + Future submitPayoutRequest(Map payload) async { + final response = await _client.post(ApiEndpoints.requestPayout, body: payload); + return (response as Map?)?['success'] as bool? ?? true; + } +} diff --git a/apps/eva_counselor_mobile/lib/features/finances/data/models/transaction_dto.dart b/apps/eva_counselor_mobile/lib/features/finances/data/models/transaction_dto.dart new file mode 100644 index 0000000..9dc4337 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/finances/data/models/transaction_dto.dart @@ -0,0 +1,121 @@ +import '../../domain/entities/transaction_entity.dart'; + +class FinancialSummaryResponseDto { + final int availableBalance; + final int pendingClearingBalance; + final int totalEarningsThisMonth; + final int totalWithdrawn; + + const FinancialSummaryResponseDto({ + required this.availableBalance, + required this.pendingClearingBalance, + required this.totalEarningsThisMonth, + required this.totalWithdrawn, + }); + + factory FinancialSummaryResponseDto.fromJson(Map json) { + return FinancialSummaryResponseDto( + availableBalance: json['available_balance'] as int? ?? 0, + pendingClearingBalance: json['pending_clearing_balance'] as int? ?? 0, + totalEarningsThisMonth: json['total_earnings_this_month'] as int? ?? 0, + totalWithdrawn: json['total_withdrawn'] as int? ?? 0, + ); + } + + Map toJson() => { + 'available_balance': availableBalance, + 'pending_clearing_balance': pendingClearingBalance, + 'total_earnings_this_month': totalEarningsThisMonth, + 'total_withdrawn': totalWithdrawn, + }; + + FinancialSummaryEntity toEntity() { + return FinancialSummaryEntity( + availableBalance: availableBalance, + pendingClearingBalance: pendingClearingBalance, + totalEarningsThisMonth: totalEarningsThisMonth, + totalWithdrawn: totalWithdrawn, + ); + } +} + +class TransactionResponseDto { + final String id; + final String patientName; + final String sessionType; + final String date; + final int amount; + final int adminFee; + final int netEarnings; + final String status; + final String? bankAccountInfo; + + const TransactionResponseDto({ + required this.id, + required this.patientName, + required this.sessionType, + required this.date, + required this.amount, + required this.adminFee, + required this.netEarnings, + required this.status, + this.bankAccountInfo, + }); + + factory TransactionResponseDto.fromJson(Map json) { + return TransactionResponseDto( + id: json['id']?.toString() ?? '', + patientName: json['patient_name']?.toString() ?? 'Pasien', + sessionType: json['session_type']?.toString() ?? 'Konsultasi Video 50 Menit', + date: json['date']?.toString() ?? DateTime.now().toIso8601String(), + amount: json['amount'] as int? ?? 0, + adminFee: json['admin_fee'] as int? ?? 0, + netEarnings: json['net_earnings'] as int? ?? 0, + status: json['status']?.toString() ?? 'completed', + bankAccountInfo: json['bank_account_info']?.toString(), + ); + } + + Map toJson() => { + 'id': id, + 'patient_name': patientName, + 'session_type': sessionType, + 'date': date, + 'amount': amount, + 'admin_fee': adminFee, + 'net_earnings': netEarnings, + 'status': status, + 'bank_account_info': bankAccountInfo, + }; + + TransactionEntity toEntity() { + TransactionStatus s; + switch (status.toLowerCase()) { + case 'pending': + s = TransactionStatus.pending; + break; + case 'payout_requested': + s = TransactionStatus.payoutRequested; + break; + case 'refunded': + s = TransactionStatus.refunded; + break; + case 'completed': + default: + s = TransactionStatus.completed; + break; + } + + return TransactionEntity( + id: id, + patientName: patientName, + sessionType: sessionType, + date: DateTime.tryParse(date) ?? DateTime.now(), + amount: amount, + adminFee: adminFee, + netEarnings: netEarnings, + status: s, + bankAccountInfo: bankAccountInfo, + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/finances/data/repositories/finance_repository_impl.dart b/apps/eva_counselor_mobile/lib/features/finances/data/repositories/finance_repository_impl.dart new file mode 100644 index 0000000..8ad00a9 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/finances/data/repositories/finance_repository_impl.dart @@ -0,0 +1,37 @@ +import '../../domain/entities/transaction_entity.dart'; +import '../../domain/repositories/finance_repository.dart'; +import '../datasources/finance_datasource.dart'; + +class FinanceRepositoryImpl implements IFinanceRepository { + final FinanceDataSource _dataSource; + + FinanceRepositoryImpl({required FinanceDataSource dataSource}) + : _dataSource = dataSource; + + @override + Future getSummary() async { + final dto = await _dataSource.fetchSummary(); + return dto.toEntity(); + } + + @override + Future> getTransactions() async { + final list = await _dataSource.fetchTransactions(); + return list.map((dto) => dto.toEntity()).toList(); + } + + @override + Future requestPayout({ + required int amount, + required String bankName, + required String accountNumber, + required String accountHolderName, + }) async { + return await _dataSource.submitPayoutRequest({ + 'amount': amount, + 'bank_name': bankName, + 'account_number': accountNumber, + 'account_holder_name': accountHolderName, + }); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/finances/domain/entities/transaction_entity.dart b/apps/eva_counselor_mobile/lib/features/finances/domain/entities/transaction_entity.dart new file mode 100644 index 0000000..f9c64d7 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/finances/domain/entities/transaction_entity.dart @@ -0,0 +1,39 @@ +enum TransactionStatus { completed, pending, payoutRequested, refunded } + +class FinancialSummaryEntity { + final int availableBalance; // Rp saldo siap tarik + final int pendingClearingBalance; // Rp menunggu kliring + final int totalEarningsThisMonth; // Rp total bulan ini + final int totalWithdrawn; // Rp sudah ditarik + + const FinancialSummaryEntity({ + required this.availableBalance, + required this.pendingClearingBalance, + required this.totalEarningsThisMonth, + required this.totalWithdrawn, + }); +} + +class TransactionEntity { + final String id; + final String patientName; + final String sessionType; // e.g. 'Telekonsultasi Video 50 Menit' + final DateTime date; + final int amount; // Rp + final int adminFee; // Rp + final int netEarnings; // Rp + final TransactionStatus status; + final String? bankAccountInfo; + + const TransactionEntity({ + required this.id, + required this.patientName, + required this.sessionType, + required this.date, + required this.amount, + required this.adminFee, + required this.netEarnings, + required this.status, + this.bankAccountInfo, + }); +} diff --git a/apps/eva_counselor_mobile/lib/features/finances/domain/repositories/finance_repository.dart b/apps/eva_counselor_mobile/lib/features/finances/domain/repositories/finance_repository.dart new file mode 100644 index 0000000..daeb506 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/finances/domain/repositories/finance_repository.dart @@ -0,0 +1,12 @@ +import '../entities/transaction_entity.dart'; + +abstract class IFinanceRepository { + Future getSummary(); + Future> getTransactions(); + Future requestPayout({ + required int amount, + required String bankName, + required String accountNumber, + required String accountHolderName, + }); +} diff --git a/apps/eva_counselor_mobile/lib/features/finances/presentation/providers/finance_provider.dart b/apps/eva_counselor_mobile/lib/features/finances/presentation/providers/finance_provider.dart new file mode 100644 index 0000000..5db9398 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/finances/presentation/providers/finance_provider.dart @@ -0,0 +1,30 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/config/app_config.dart'; +import '../../domain/entities/transaction_entity.dart'; +import '../../domain/repositories/finance_repository.dart'; +import '../../data/datasources/finance_datasource.dart'; +import '../../data/datasources/mock_finance_datasource.dart'; +import '../../data/datasources/remote_finance_datasource.dart'; +import '../../data/repositories/finance_repository_impl.dart'; + +final financeDataSourceProvider = Provider((ref) { + if (AppConfig.useMockData) { + return MockFinanceDataSource(); + } + return RemoteFinanceDataSource(); +}); + +final financeRepositoryProvider = Provider((ref) { + final ds = ref.watch(financeDataSourceProvider); + return FinanceRepositoryImpl(dataSource: ds); +}); + +final financialSummaryProvider = FutureProvider((ref) async { + final repo = ref.watch(financeRepositoryProvider); + return await repo.getSummary(); +}); + +final transactionsListProvider = FutureProvider>((ref) async { + final repo = ref.watch(financeRepositoryProvider); + return await repo.getTransactions(); +}); diff --git a/apps/eva_counselor_mobile/lib/features/finances/presentation/screens/transaction_history_screen.dart b/apps/eva_counselor_mobile/lib/features/finances/presentation/screens/transaction_history_screen.dart new file mode 100644 index 0000000..fef958a --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/finances/presentation/screens/transaction_history_screen.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/custom_button.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../../../../core/widgets/top_toast.dart'; +import '../../domain/entities/transaction_entity.dart'; +import '../providers/finance_provider.dart'; + +class TransactionHistoryScreen extends ConsumerStatefulWidget { + const TransactionHistoryScreen({super.key}); + + @override + ConsumerState createState() => + _TransactionHistoryScreenState(); +} + +class _TransactionHistoryScreenState + extends ConsumerState { + String _selectedFilter = 'Semua'; + + void _showPayoutModal(BuildContext context, int availableBalance) { + final amountCtrl = TextEditingController(text: availableBalance.toString()); + final bankCtrl = TextEditingController(text: 'BCA'); + final accountCtrl = TextEditingController(text: '8192039182'); + final nameCtrl = TextEditingController(text: 'Sarah Anindya'); + bool isSubmitting = false; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (context, setModalState) { + return Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Tarik Honorarium Konsultasi', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Saldo siap ditarik: Rp ${_formatRp(availableBalance)}', + style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 13), + ), + const SizedBox(height: 16), + _buildModalInput('Nominal Penarikan (Rp)', amountCtrl, isNumber: true), + const SizedBox(height: 12), + _buildModalInput('Nama Bank Tujuan', bankCtrl), + const SizedBox(height: 12), + _buildModalInput('Nomor Rekening', accountCtrl, isNumber: true), + const SizedBox(height: 12), + _buildModalInput('Nama Pemilik Rekening (Sesuai KTP)', nameCtrl), + const SizedBox(height: 20), + CustomButton( + label: 'Konfirmasi Penarikan Dana', + isLoading: isSubmitting, + onPressed: () async { + setModalState(() => isSubmitting = true); + final repo = ref.read(financeRepositoryProvider); + await repo.requestPayout( + amount: int.tryParse(amountCtrl.text) ?? 0, + bankName: bankCtrl.text, + accountNumber: accountCtrl.text, + accountHolderName: nameCtrl.text, + ); + ref.invalidate(financialSummaryProvider); + ref.invalidate(transactionsListProvider); + if (!ctx.mounted) return; + Navigator.of(ctx).pop(); + if (!context.mounted) return; + TopToast.show( + context, + title: 'Pencairan Berhasil Diajukan', + message: 'Permintaan transfer honorarium sebesar Rp ${_formatRp(int.tryParse(amountCtrl.text) ?? 0)} sedang diproses.', + type: ToastType.success, + ); + }, + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildModalInput(String label, TextEditingController ctrl, {bool isNumber = false}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 4), + Container( + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(12), + ), + child: TextField( + controller: ctrl, + keyboardType: isNumber ? TextInputType.number : TextInputType.text, + style: const TextStyle(fontSize: 13), + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + ), + ), + ], + ); + } + + String _formatRp(int amount) { + return amount.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (m) => '${m[1]}.', + ); + } + + @override + Widget build(BuildContext context) { + final summaryAsync = ref.watch(financialSummaryProvider); + final txListAsync = ref.watch(transactionsListProvider); + + return Scaffold( + backgroundColor: const Color(0xFFF8F9FE), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Balance Overview Card (Soft Indigo Gradient) + summaryAsync.when( + data: (summary) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF4B53BB), Color(0xFF6B72E5)], + ), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: const Color(0xFF4B53BB).withValues(alpha: 0.25), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Saldo Tersedia (Siap Tarik)', + style: TextStyle(color: Colors.white70, fontSize: 12, fontWeight: FontWeight.w500), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: const Text('BCA Terhubung', style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Rp ${_formatRp(summary.availableBalance)}', + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Menunggu Kliring', style: TextStyle(color: Colors.white70, fontSize: 10)), + const SizedBox(height: 2), + Text( + 'Rp ${_formatRp(summary.pendingClearingBalance)}', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), + ), + ], + ), + Container(width: 1, height: 24, color: Colors.white24), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Total Bulan Ini', style: TextStyle(color: Colors.white70, fontSize: 10)), + const SizedBox(height: 2), + Text( + 'Rp ${_formatRp(summary.totalEarningsThisMonth)}', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.primary, + minimumSize: const Size.fromHeight(46), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + icon: const Icon(Icons.arrow_upward_rounded, size: 18), + label: const Text('Tarik Dana ke Rekening', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + onPressed: () => _showPayoutModal(context, summary.availableBalance), + ), + ], + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error: $e'), + ), + const SizedBox(height: 24), + + // 2. Filter Pills + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Riwayat Transaksi', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: ['Semua', 'Selesai', 'Penarikan'].map((f) { + final isSel = _selectedFilter == f; + return Padding( + padding: const EdgeInsets.only(left: 6), + child: GestureDetector( + onTap: () => setState(() => _selectedFilter = f), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSel ? AppColors.primary : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: isSel ? AppColors.primary : const Color(0xFFECEFF8)), + ), + child: Text( + f, + style: TextStyle( + fontSize: 11, + fontWeight: isSel ? FontWeight.bold : FontWeight.w500, + color: isSel ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ), + const SizedBox(height: 12), + + // 3. Transactions List (Soft Cards, No Harsh Lines) + txListAsync.when( + data: (transactions) { + final filtered = transactions.where((t) { + if (_selectedFilter == 'Selesai') { + return t.status == TransactionStatus.completed; + } + if (_selectedFilter == 'Penarikan') { + return t.status == TransactionStatus.payoutRequested; + } + return true; + }).toList(); + + if (filtered.isEmpty) { + return Container( + padding: const EdgeInsets.all(24), + alignment: Alignment.center, + child: const Text('Tidak ada riwayat transaksi.', style: TextStyle(color: AppColors.textSecondary)), + ); + } + + return Column( + children: filtered.map((t) { + final isPayout = t.status == TransactionStatus.payoutRequested; + + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: isPayout ? AppColors.pendingBg : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + isPayout ? Icons.account_balance_outlined : Icons.videocam_outlined, + color: isPayout ? AppColors.pendingAmber : AppColors.primary, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.patientName, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 2), + Text( + '${t.sessionType} â€ĸ ${t.date.day}/${t.date.month}/${t.date.year}', + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${isPayout ? '-' : '+'}Rp ${_formatRp(t.netEarnings)}', + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 13, + color: isPayout ? AppColors.textPrimary : AppColors.verifiedGreen, + ), + ), + const SizedBox(height: 3), + isPayout + ? StatusBadge.pending('Diproses') + : StatusBadge.verified('Selesai'), + ], + ), + ], + ), + ); + }).toList(), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error: $e'), + ), + const SizedBox(height: 80), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/patients/data/datasources/mock_patient_datasource.dart b/apps/eva_counselor_mobile/lib/features/patients/data/datasources/mock_patient_datasource.dart new file mode 100644 index 0000000..73f9f4f --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/data/datasources/mock_patient_datasource.dart @@ -0,0 +1,221 @@ +import '../models/clinical_note_dto.dart'; +import '../models/patient_dto.dart'; +import 'patient_datasource.dart'; + +class MockPatientDataSource implements PatientDataSource { + final List _mockPatients = [ + PatientResponseDto( + id: 'patient-001', + name: 'Rian Pratama', + age: 26, + gender: 'Laki-laki', + chiefComplaint: 'Kecemasan akademik & insomnia menjelang ujian tesis', + latestMoodTag: 'Cemas Sedang 😟', + emotionalRiskScore: 6, + isHighRisk: false, + hasConsent: true, // Consent granted: Psychologist can see details + lastSessionDate: DateTime.now().subtract(const Duration(days: 2)).toIso8601String(), + totalCompletedSessions: 3, + dassHistory: [ + DassAssessmentDto( + date: DateTime.now().subtract(const Duration(days: 14)).toIso8601String(), + depressionScore: 12, + anxietyScore: 18, + stressScore: 22, + severityLabel: 'Kecemasan Sedang - Stres Parah', + ), + DassAssessmentDto( + date: DateTime.now().subtract(const Duration(days: 2)).toIso8601String(), + depressionScore: 8, + anxietyScore: 12, + stressScore: 14, + severityLabel: 'Membaik (Stres Ringan)', + ), + ], + sharedDiarySummaries: [ + 'Sesi 1: Pasien mengeluhkan palpitasi jantung saat memikirkan revisi bab 4.', + 'Sesi 2: Pasien mencoba teknik grounding 5-4-3-2-1 dan merasa lebih stabil.', + 'Sesi 3: Pasien berhasil tidur 6 jam tanpa terbangun tengah malam.', + ], + upcomingSchedule: const PatientScheduleDto( + formattedTime: 'Hari Ini â€ĸ 10:00 - 10:50 WIB', + dateLabel: 'Selasa, 11 Nov 2026', + timeRange: '10:00 - 10:50 WIB', + isLiveNow: true, + roomOpensAt: '09:45 WIB (Telah Dibuka)', + statusText: 'Sesi Siap Dimulai (Live)', + sessionId: 'session-live-001', + ), + ), + PatientResponseDto( + id: 'patient-002', + name: 'Dinda Kirana', + age: 23, + gender: 'Perempuan', + chiefComplaint: 'Burnout pekerjaan, overthinking berat dan serangan panik', + latestMoodTag: 'Indikasi Krisis Darurat 🚨', + emotionalRiskScore: 9, + isHighRisk: true, // High Risk Alert! + hasConsent: true, + lastSessionDate: DateTime.now().subtract(const Duration(days: 1)).toIso8601String(), + totalCompletedSessions: 1, + dassHistory: [ + DassAssessmentDto( + date: DateTime.now().subtract(const Duration(days: 1)).toIso8601String(), + depressionScore: 24, + anxietyScore: 28, + stressScore: 32, + severityLabel: 'Sangat Parah (Protokol De-eskalasi Aktif)', + ), + ], + sharedDiarySummaries: [ + 'Sesi 1: Krisis kecemasan akut. Protokol keselamatan 119 ext 8 diaktifkan.', + ], + upcomingSchedule: const PatientScheduleDto( + formattedTime: 'Hari Ini â€ĸ 14:00 - 14:50 WIB', + dateLabel: 'Selasa, 11 Nov 2026', + timeRange: '14:00 - 14:50 WIB', + isLiveNow: false, + roomOpensAt: '13:45 WIB', + statusText: 'Menunggu Waktu Sesi (14:00 WIB)', + sessionId: 'session-live-002', + ), + ), + PatientResponseDto( + id: 'patient-003', + name: 'Aditya Nugraha', + age: 29, + gender: 'Laki-laki', + chiefComplaint: 'Masalah komunikasi relasi dan regulasi emosi amarah', + latestMoodTag: 'Khawatir 😐', + emotionalRiskScore: 4, + isHighRisk: false, + hasConsent: false, // Consent NOT granted yet! Testing privacy guard + lastSessionDate: DateTime.now().subtract(const Duration(days: 5)).toIso8601String(), + totalCompletedSessions: 2, + consentRequestedAt: null, + dassHistory: null, + sharedDiarySummaries: null, + upcomingSchedule: null, // Sesi lalu selesai, belum ada reservasi baru + ), + PatientResponseDto( + id: 'patient-004', + name: 'Maya Lestari', + age: 21, + gender: 'Perempuan', + chiefComplaint: 'Krisis identitas kuartal hidup (Quarter-life crisis)', + latestMoodTag: 'Tenang & Nyaman đŸŒŋ', + emotionalRiskScore: 3, + isHighRisk: false, + hasConsent: true, + lastSessionDate: DateTime.now().subtract(const Duration(days: 8)).toIso8601String(), + totalCompletedSessions: 4, + dassHistory: [ + DassAssessmentDto( + date: DateTime.now().subtract(const Duration(days: 8)).toIso8601String(), + depressionScore: 6, + anxietyScore: 6, + stressScore: 10, + severityLabel: 'Normal - Terkendali Baik', + ), + ], + sharedDiarySummaries: [ + 'Sesi 4: Pasien merasa tujuan kariernya sudah mulai jelas dan optimis.', + ], + upcomingSchedule: const PatientScheduleDto( + formattedTime: 'Besok Rabu, 12 Nov â€ĸ 11:00 - 11:50 WIB', + dateLabel: 'Rabu, 12 Nov 2026', + timeRange: '11:00 - 11:50 WIB', + isLiveNow: false, + roomOpensAt: 'Rabu, 10:45 WIB', + statusText: 'Terkonfirmasi Mendatang', + sessionId: 'session-live-004', + ), + ), + ]; + + final Map> _mockNotes = { + 'patient-001': [ + ClinicalNoteResponseDto( + id: 'note-001', + patientId: 'patient-001', + sessionId: 'session-prev-001', + createdAt: DateTime.now().subtract(const Duration(days: 2)).toIso8601String(), + subjective: 'Pasien menyatakan masih cemas jika dosen pembimbing membalas chat.', + objective: 'Kontak mata membaik, nafas lebih teratur dibanding sesi pertama.', + assessment: 'Kecemasan performansi akademik dengan distorsi kognitif katastrofik.', + plan: 'Latihan Cognitive Restructuring, restrukturisasi pikiran negatif, pacer napas.', + recommendedTasks: [ + 'Jurnal Pikiran Otomatis (CBT Thought Record)', + 'Latihan pernapasan diafragma 5 menit sebelum membuka revisi', + ], + ), + ], + }; + + @override + Future> fetchPatients() async { + await Future.delayed(const Duration(milliseconds: 500)); + return _mockPatients; + } + + @override + Future fetchPatientDetail(String patientId) async { + await Future.delayed(const Duration(milliseconds: 300)); + return _mockPatients.firstWhere( + (p) => p.id == patientId, + orElse: () => _mockPatients.first, + ); + } + + @override + Future sendConsentRequest(String patientId) async { + await Future.delayed(const Duration(milliseconds: 400)); + final idx = _mockPatients.indexWhere((p) => p.id == patientId); + if (idx != -1) { + final p = _mockPatients[idx]; + _mockPatients[idx] = PatientResponseDto( + id: p.id, + name: p.name, + age: p.age, + gender: p.gender, + chiefComplaint: p.chiefComplaint, + latestMoodTag: p.latestMoodTag, + emotionalRiskScore: p.emotionalRiskScore, + isHighRisk: p.isHighRisk, + hasConsent: false, + lastSessionDate: p.lastSessionDate, + totalCompletedSessions: p.totalCompletedSessions, + consentRequestedAt: DateTime.now().toIso8601String(), + dassHistory: p.dassHistory, + sharedDiarySummaries: p.sharedDiarySummaries, + ); + } + return true; + } + + @override + Future> fetchClinicalNotes(String patientId) async { + await Future.delayed(const Duration(milliseconds: 300)); + return _mockNotes[patientId] ?? []; + } + + @override + Future createClinicalNote( + CreateClinicalNoteRequestDto request) async { + await Future.delayed(const Duration(milliseconds: 500)); + final newNote = ClinicalNoteResponseDto( + id: 'note-${DateTime.now().millisecondsSinceEpoch}', + patientId: request.patientId, + sessionId: request.sessionId, + createdAt: DateTime.now().toIso8601String(), + subjective: request.subjective, + objective: request.objective, + assessment: request.assessment, + plan: request.plan, + recommendedTasks: request.recommendedTasks, + ); + _mockNotes.putIfAbsent(request.patientId, () => []).insert(0, newNote); + return newNote; + } +} diff --git a/apps/eva_counselor_mobile/lib/features/patients/data/datasources/patient_datasource.dart b/apps/eva_counselor_mobile/lib/features/patients/data/datasources/patient_datasource.dart new file mode 100644 index 0000000..76bc37f --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/data/datasources/patient_datasource.dart @@ -0,0 +1,10 @@ +import '../models/clinical_note_dto.dart'; +import '../models/patient_dto.dart'; + +abstract class PatientDataSource { + Future> fetchPatients(); + Future fetchPatientDetail(String patientId); + Future sendConsentRequest(String patientId); + Future> fetchClinicalNotes(String patientId); + Future createClinicalNote(CreateClinicalNoteRequestDto request); +} diff --git a/apps/eva_counselor_mobile/lib/features/patients/data/datasources/remote_patient_datasource.dart b/apps/eva_counselor_mobile/lib/features/patients/data/datasources/remote_patient_datasource.dart new file mode 100644 index 0000000..0f010c0 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/data/datasources/remote_patient_datasource.dart @@ -0,0 +1,49 @@ +import '../../../../core/network/api_client.dart'; +import '../../../../core/network/api_endpoints.dart'; +import '../models/clinical_note_dto.dart'; +import '../models/patient_dto.dart'; +import 'patient_datasource.dart'; + +class RemotePatientDataSource implements PatientDataSource { + final ApiClient _client; + + RemotePatientDataSource({ApiClient? client}) : _client = client ?? ApiClient(); + + @override + Future> fetchPatients() async { + final response = await _client.get(ApiEndpoints.patients); + return (response as List) + .map((p) => PatientResponseDto.fromJson(p as Map)) + .toList(); + } + + @override + Future fetchPatientDetail(String patientId) async { + final response = await _client.get(ApiEndpoints.patientDetail(patientId)); + return PatientResponseDto.fromJson(response as Map); + } + + @override + Future sendConsentRequest(String patientId) async { + final response = await _client.post(ApiEndpoints.requestConsent(patientId)); + return (response as Map?)?['success'] as bool? ?? true; + } + + @override + Future> fetchClinicalNotes(String patientId) async { + final response = await _client.get(ApiEndpoints.clinicalNotes(patientId)); + return (response as List) + .map((n) => ClinicalNoteResponseDto.fromJson(n as Map)) + .toList(); + } + + @override + Future createClinicalNote( + CreateClinicalNoteRequestDto request) async { + final response = await _client.post( + ApiEndpoints.clinicalNotes(request.patientId), + body: request.toJson(), + ); + return ClinicalNoteResponseDto.fromJson(response as Map); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/patients/data/models/clinical_note_dto.dart b/apps/eva_counselor_mobile/lib/features/patients/data/models/clinical_note_dto.dart new file mode 100644 index 0000000..0f9b25c --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/data/models/clinical_note_dto.dart @@ -0,0 +1,86 @@ +import '../../domain/entities/clinical_note_entity.dart'; + +class CreateClinicalNoteRequestDto { + final String patientId; + final String sessionId; + final String subjective; + final String objective; + final String assessment; + final String plan; + final List recommendedTasks; + + const CreateClinicalNoteRequestDto({ + required this.patientId, + required this.sessionId, + required this.subjective, + required this.objective, + required this.assessment, + required this.plan, + required this.recommendedTasks, + }); + + Map toJson() => { + 'patient_id': patientId, + 'session_id': sessionId, + 'subjective': subjective, + 'objective': objective, + 'assessment': assessment, + 'plan': plan, + 'recommended_tasks': recommendedTasks, + }; +} + +class ClinicalNoteResponseDto { + final String id; + final String patientId; + final String sessionId; + final String createdAt; + final String subjective; + final String objective; + final String assessment; + final String plan; + final List recommendedTasks; + + const ClinicalNoteResponseDto({ + required this.id, + required this.patientId, + required this.sessionId, + required this.createdAt, + required this.subjective, + required this.objective, + required this.assessment, + required this.plan, + required this.recommendedTasks, + }); + + factory ClinicalNoteResponseDto.fromJson(Map json) { + return ClinicalNoteResponseDto( + id: json['id']?.toString() ?? '', + patientId: json['patient_id']?.toString() ?? '', + sessionId: json['session_id']?.toString() ?? '', + createdAt: json['created_at']?.toString() ?? DateTime.now().toIso8601String(), + subjective: json['subjective']?.toString() ?? '', + objective: json['objective']?.toString() ?? '', + assessment: json['assessment']?.toString() ?? '', + plan: json['plan']?.toString() ?? '', + recommendedTasks: (json['recommended_tasks'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + ); + } + + ClinicalNoteEntity toEntity() { + return ClinicalNoteEntity( + id: id, + patientId: patientId, + sessionId: sessionId, + createdAt: DateTime.tryParse(createdAt) ?? DateTime.now(), + subjective: subjective, + objective: objective, + assessment: assessment, + plan: plan, + recommendedTasks: recommendedTasks, + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/patients/data/models/patient_dto.dart b/apps/eva_counselor_mobile/lib/features/patients/data/models/patient_dto.dart new file mode 100644 index 0000000..6f77823 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/data/models/patient_dto.dart @@ -0,0 +1,200 @@ +import '../../domain/entities/patient_entity.dart'; + +class PatientResponseDto { + final String id; + final String name; + final int age; + final String gender; + final String chiefComplaint; + final String latestMoodTag; + final int emotionalRiskScore; + final bool isHighRisk; + final bool hasConsent; + final String lastSessionDate; + final int totalCompletedSessions; + final String? consentRequestedAt; + final List? dassHistory; + final List? sharedDiarySummaries; + final PatientScheduleDto? upcomingSchedule; + + const PatientResponseDto({ + required this.id, + required this.name, + required this.age, + required this.gender, + required this.chiefComplaint, + required this.latestMoodTag, + required this.emotionalRiskScore, + required this.isHighRisk, + required this.hasConsent, + required this.lastSessionDate, + required this.totalCompletedSessions, + this.consentRequestedAt, + this.dassHistory, + this.sharedDiarySummaries, + this.upcomingSchedule, + }); + + factory PatientResponseDto.fromJson(Map json) { + return PatientResponseDto( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? 'Pasien', + age: json['age'] as int? ?? 25, + gender: json['gender']?.toString() ?? 'L', + chiefComplaint: json['chief_complaint']?.toString() ?? '-', + latestMoodTag: json['latest_mood_tag']?.toString() ?? 'Tenang & Nyaman', + emotionalRiskScore: json['emotional_risk_score'] as int? ?? 2, + isHighRisk: json['is_high_risk'] as bool? ?? false, + hasConsent: json['has_consent'] as bool? ?? false, + lastSessionDate: json['last_session_date']?.toString() ?? + DateTime.now().toIso8601String(), + totalCompletedSessions: json['total_completed_sessions'] as int? ?? 0, + consentRequestedAt: json['consent_requested_at']?.toString(), + dassHistory: (json['dass_history'] as List?) + ?.map((d) => DassAssessmentDto.fromJson(d as Map)) + .toList(), + sharedDiarySummaries: (json['shared_diary_summaries'] as List?) + ?.map((e) => e.toString()) + .toList(), + upcomingSchedule: json['upcoming_schedule'] != null + ? PatientScheduleDto.fromJson(json['upcoming_schedule'] as Map) + : null, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'age': age, + 'gender': gender, + 'chief_complaint': chiefComplaint, + 'latest_mood_tag': latestMoodTag, + 'emotional_risk_score': emotionalRiskScore, + 'is_high_risk': isHighRisk, + 'has_consent': hasConsent, + 'last_session_date': lastSessionDate, + 'total_completed_sessions': totalCompletedSessions, + 'consent_requested_at': consentRequestedAt, + 'dass_history': dassHistory?.map((e) => e.toJson()).toList(), + 'shared_diary_summaries': sharedDiarySummaries, + 'upcoming_schedule': upcomingSchedule?.toJson(), + }; + + PatientEntity toEntity() { + return PatientEntity( + id: id, + name: name, + age: age, + gender: gender, + chiefComplaint: chiefComplaint, + latestMoodTag: latestMoodTag, + emotionalRiskScore: emotionalRiskScore, + isHighRisk: isHighRisk, + hasConsent: hasConsent, + lastSessionDate: DateTime.tryParse(lastSessionDate) ?? DateTime.now(), + totalCompletedSessions: totalCompletedSessions, + consentRequestedAt: consentRequestedAt, + dassHistory: dassHistory?.map((e) => e.toEntity()).toList(), + sharedDiarySummaries: sharedDiarySummaries, + upcomingSchedule: upcomingSchedule?.toEntity(), + ); + } +} + +class PatientScheduleDto { + final String formattedTime; + final String dateLabel; + final String timeRange; + final bool isLiveNow; + final String roomOpensAt; + final String statusText; + final String? sessionId; + + const PatientScheduleDto({ + required this.formattedTime, + required this.dateLabel, + required this.timeRange, + required this.isLiveNow, + required this.roomOpensAt, + required this.statusText, + this.sessionId, + }); + + factory PatientScheduleDto.fromJson(Map json) { + return PatientScheduleDto( + formattedTime: json['formatted_time']?.toString() ?? '', + dateLabel: json['date_label']?.toString() ?? '', + timeRange: json['time_range']?.toString() ?? '', + isLiveNow: json['is_live_now'] as bool? ?? false, + roomOpensAt: json['room_opens_at']?.toString() ?? '', + statusText: json['status_text']?.toString() ?? '', + sessionId: json['session_id']?.toString(), + ); + } + + Map toJson() => { + 'formatted_time': formattedTime, + 'date_label': dateLabel, + 'time_range': timeRange, + 'is_live_now': isLiveNow, + 'room_opens_at': roomOpensAt, + 'status_text': statusText, + 'session_id': sessionId, + }; + + PatientScheduleInfo toEntity() { + return PatientScheduleInfo( + formattedTime: formattedTime, + dateLabel: dateLabel, + timeRange: timeRange, + isLiveNow: isLiveNow, + roomOpensAt: roomOpensAt, + statusText: statusText, + sessionId: sessionId, + ); + } +} + +class DassAssessmentDto { + final String date; + final int depressionScore; + final int anxietyScore; + final int stressScore; + final String severityLabel; + + const DassAssessmentDto({ + required this.date, + required this.depressionScore, + required this.anxietyScore, + required this.stressScore, + required this.severityLabel, + }); + + factory DassAssessmentDto.fromJson(Map json) { + return DassAssessmentDto( + date: json['date']?.toString() ?? DateTime.now().toIso8601String(), + depressionScore: json['depression_score'] as int? ?? 0, + anxietyScore: json['anxiety_score'] as int? ?? 0, + stressScore: json['stress_score'] as int? ?? 0, + severityLabel: json['severity_label']?.toString() ?? 'Normal', + ); + } + + Map toJson() => { + 'date': date, + 'depression_score': depressionScore, + 'anxiety_score': anxietyScore, + 'stress_score': stressScore, + 'severity_label': severityLabel, + }; + + DassAssessmentRecord toEntity() { + return DassAssessmentRecord( + date: DateTime.tryParse(date) ?? DateTime.now(), + depressionScore: depressionScore, + anxietyScore: anxietyScore, + stressScore: stressScore, + severityLabel: severityLabel, + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/patients/data/repositories/patient_repository_impl.dart b/apps/eva_counselor_mobile/lib/features/patients/data/repositories/patient_repository_impl.dart new file mode 100644 index 0000000..0352fe2 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/data/repositories/patient_repository_impl.dart @@ -0,0 +1,58 @@ +import '../../domain/entities/clinical_note_entity.dart'; +import '../../domain/entities/patient_entity.dart'; +import '../../domain/repositories/patient_repository.dart'; +import '../datasources/patient_datasource.dart'; +import '../models/clinical_note_dto.dart'; + +class PatientRepositoryImpl implements IPatientRepository { + final PatientDataSource _dataSource; + + PatientRepositoryImpl({required PatientDataSource dataSource}) + : _dataSource = dataSource; + + @override + Future> getPatients() async { + final list = await _dataSource.fetchPatients(); + return list.map((dto) => dto.toEntity()).toList(); + } + + @override + Future getPatientDetail(String patientId) async { + final dto = await _dataSource.fetchPatientDetail(patientId); + return dto.toEntity(); + } + + @override + Future requestPatientConsent(String patientId) async { + return await _dataSource.sendConsentRequest(patientId); + } + + @override + Future> getClinicalNotes(String patientId) async { + final dtos = await _dataSource.fetchClinicalNotes(patientId); + return dtos.map((dto) => dto.toEntity()).toList(); + } + + @override + Future saveClinicalNote({ + required String patientId, + required String sessionId, + required String subjective, + required String objective, + required String assessment, + required String plan, + required List recommendedTasks, + }) async { + final request = CreateClinicalNoteRequestDto( + patientId: patientId, + sessionId: sessionId, + subjective: subjective, + objective: objective, + assessment: assessment, + plan: plan, + recommendedTasks: recommendedTasks, + ); + final dto = await _dataSource.createClinicalNote(request); + return dto.toEntity(); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/patients/domain/entities/clinical_note_entity.dart b/apps/eva_counselor_mobile/lib/features/patients/domain/entities/clinical_note_entity.dart new file mode 100644 index 0000000..fee7452 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/domain/entities/clinical_note_entity.dart @@ -0,0 +1,23 @@ +class ClinicalNoteEntity { + final String id; + final String patientId; + final String sessionId; + final DateTime createdAt; + final String subjective; // Keluhan pasien + final String objective; // Observasi afek & perilaku + final String assessment; // Analisis klinis + final String plan; // Terapi lanjutan & PR + final List recommendedTasks; + + const ClinicalNoteEntity({ + required this.id, + required this.patientId, + required this.sessionId, + required this.createdAt, + required this.subjective, + required this.objective, + required this.assessment, + required this.plan, + required this.recommendedTasks, + }); +} diff --git a/apps/eva_counselor_mobile/lib/features/patients/domain/entities/patient_entity.dart b/apps/eva_counselor_mobile/lib/features/patients/domain/entities/patient_entity.dart new file mode 100644 index 0000000..0fa797e --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/domain/entities/patient_entity.dart @@ -0,0 +1,71 @@ +class PatientEntity { + final String id; + final String name; // e.g., 'Budi Santoso' + final int age; + final String gender; + final String chiefComplaint; // Keluhan utama + final String latestMoodTag; // e.g. 'Cemas Sedang', 'Tenang & Nyaman' + final int emotionalRiskScore; // 1-10 + final bool isHighRisk; // Emergency alert trigger + final bool hasConsent; // True if patient gave access to emotional record & DASS + final DateTime lastSessionDate; + final int totalCompletedSessions; + final String? consentRequestedAt; + final List? dassHistory; + final List? sharedDiarySummaries; + final PatientScheduleInfo? upcomingSchedule; + + const PatientEntity({ + required this.id, + required this.name, + required this.age, + required this.gender, + required this.chiefComplaint, + required this.latestMoodTag, + required this.emotionalRiskScore, + required this.isHighRisk, + required this.hasConsent, + required this.lastSessionDate, + required this.totalCompletedSessions, + this.consentRequestedAt, + this.dassHistory, + this.sharedDiarySummaries, + this.upcomingSchedule, + }); +} + +class PatientScheduleInfo { + final String formattedTime; // e.g. 'Hari Ini â€ĸ 10:00 - 10:50 WIB' + final String dateLabel; // e.g. 'Selasa, 11 Nov 2026' + final String timeRange; // e.g. '10:00 - 10:50 WIB' + final bool isLiveNow; // true jika waktu sesi sudah masuk / toleransi 15 mnt + final String roomOpensAt; // e.g. '09:45 WIB' + final String statusText; // e.g. 'Mulai dalam 15 Mnt', 'Menunggu Waktu Sesi' + final String? sessionId; + + const PatientScheduleInfo({ + required this.formattedTime, + required this.dateLabel, + required this.timeRange, + required this.isLiveNow, + required this.roomOpensAt, + required this.statusText, + this.sessionId, + }); +} + +class DassAssessmentRecord { + final DateTime date; + final int depressionScore; + final int anxietyScore; + final int stressScore; + final String severityLabel; + + const DassAssessmentRecord({ + required this.date, + required this.depressionScore, + required this.anxietyScore, + required this.stressScore, + required this.severityLabel, + }); +} diff --git a/apps/eva_counselor_mobile/lib/features/patients/domain/repositories/patient_repository.dart b/apps/eva_counselor_mobile/lib/features/patients/domain/repositories/patient_repository.dart new file mode 100644 index 0000000..98a2eef --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/domain/repositories/patient_repository.dart @@ -0,0 +1,18 @@ +import '../entities/clinical_note_entity.dart'; +import '../entities/patient_entity.dart'; + +abstract class IPatientRepository { + Future> getPatients(); + Future getPatientDetail(String patientId); + Future requestPatientConsent(String patientId); + Future> getClinicalNotes(String patientId); + Future saveClinicalNote({ + required String patientId, + required String sessionId, + required String subjective, + required String objective, + required String assessment, + required String plan, + required List recommendedTasks, + }); +} diff --git a/apps/eva_counselor_mobile/lib/features/patients/presentation/providers/patient_provider.dart b/apps/eva_counselor_mobile/lib/features/patients/presentation/providers/patient_provider.dart new file mode 100644 index 0000000..dd50ac2 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/presentation/providers/patient_provider.dart @@ -0,0 +1,38 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/config/app_config.dart'; +import '../../domain/entities/clinical_note_entity.dart'; +import '../../domain/entities/patient_entity.dart'; +import '../../domain/repositories/patient_repository.dart'; +import '../../data/datasources/patient_datasource.dart'; +import '../../data/datasources/mock_patient_datasource.dart'; +import '../../data/datasources/remote_patient_datasource.dart'; +import '../../data/repositories/patient_repository_impl.dart'; + +final patientDataSourceProvider = Provider((ref) { + if (AppConfig.useMockData) { + return MockPatientDataSource(); + } + return RemotePatientDataSource(); +}); + +final patientRepositoryProvider = Provider((ref) { + final ds = ref.watch(patientDataSourceProvider); + return PatientRepositoryImpl(dataSource: ds); +}); + +final patientsListProvider = FutureProvider>((ref) async { + final repo = ref.watch(patientRepositoryProvider); + return await repo.getPatients(); +}); + +final patientDetailProvider = + FutureProvider.family((ref, patientId) async { + final repo = ref.watch(patientRepositoryProvider); + return await repo.getPatientDetail(patientId); +}); + +final patientNotesProvider = + FutureProvider.family, String>((ref, patientId) async { + final repo = ref.watch(patientRepositoryProvider); + return await repo.getClinicalNotes(patientId); +}); diff --git a/apps/eva_counselor_mobile/lib/features/patients/presentation/screens/patient_detail_consent_screen.dart b/apps/eva_counselor_mobile/lib/features/patients/presentation/screens/patient_detail_consent_screen.dart new file mode 100644 index 0000000..a3597b8 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/presentation/screens/patient_detail_consent_screen.dart @@ -0,0 +1,1190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/custom_button.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../providers/patient_provider.dart'; + +class PatientDetailConsentScreen extends ConsumerStatefulWidget { + final String patientId; + + const PatientDetailConsentScreen({super.key, required this.patientId}); + + @override + ConsumerState createState() => + _PatientDetailConsentScreenState(); +} + +class _PatientDetailConsentScreenState + extends ConsumerState { + int _activeTabIndex = 0; // 0 = Rekam Emosional, 1 = Catatan SOAP + bool _isRequestingConsent = false; + bool _sessionUnlocked = false; + + // SOAP Note Form + final _subjCtrl = TextEditingController(); + final _objCtrl = TextEditingController(); + final _assessCtrl = TextEditingController(); + final _planCtrl = TextEditingController(); + bool _isSavingNote = false; + + void _populateLunaTranscriptDraft(dynamic patient) { + setState(() { + _sessionUnlocked = true; + _subjCtrl.text = + 'Pasien (${patient.name}) mengeluhkan kecemasan performansi akademik yang memuncak menjelang evaluasi akhir. Mengalami insomnia dan palpitasi ringan saat membuka tugas di malam hari.'; + _objCtrl.text = + 'Afek cemas dengan modulasi intonasi suara agak tegang di 10 menit awal. Kontak mata terjaga baik lewat video call, nafas lebih teratur setelah relaksasi pacer napas.'; + _assessCtrl.text = + 'Kecemasan situasional tingkat sedang (DASS-21 selaras). Belum ditemukan indikasi risiko melukai diri, responsif terhadap psikoedukasi.'; + _planCtrl.text = + '1. Praktik teknik pernapasan diafragma 4-7-8 setiap malam.\n2. CBT Thought Record di jurnal Luna.\n3. Evaluasi sesi lanjutan 1 minggu ke depan.'; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✨ Draf SOAP dari transkrip percakapan AI Luna berhasil dimuat. Anda dapat mengeditnya.'), + backgroundColor: AppColors.primary, + ), + ); + } + + void _handleRequestConsent() async { + setState(() => _isRequestingConsent = true); + final repo = ref.read(patientRepositoryProvider); + await repo.requestPatientConsent(widget.patientId); + ref.invalidate(patientDetailProvider(widget.patientId)); + ref.invalidate(patientsListProvider); + if (!mounted) return; + setState(() => _isRequestingConsent = false); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Permintaan persetujuan akses rekam emosional telah dikirim ke pasien.'), + backgroundColor: AppColors.primary, + ), + ); + } + + void _handleSaveSoapNote() async { + if (_subjCtrl.text.isEmpty || _assessCtrl.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Harap lengkapi kolom Subjective dan Assessment.'), + backgroundColor: AppColors.error, + ), + ); + return; + } + + setState(() => _isSavingNote = true); + final repo = ref.read(patientRepositoryProvider); + await repo.saveClinicalNote( + patientId: widget.patientId, + sessionId: 'session-manual-${DateTime.now().millisecondsSinceEpoch}', + subjective: _subjCtrl.text, + objective: _objCtrl.text.isNotEmpty ? _objCtrl.text : 'Afek tenang dan kooperatif.', + assessment: _assessCtrl.text, + plan: _planCtrl.text.isNotEmpty ? _planCtrl.text : 'Latihan relaksasi mandiri.', + recommendedTasks: ['Pernapasan diafragma harian', 'Journaling emosi malam'], + ); + + ref.invalidate(patientNotesProvider(widget.patientId)); + if (!mounted) return; + setState(() { + _isSavingNote = false; + _subjCtrl.clear(); + _objCtrl.clear(); + _assessCtrl.clear(); + _planCtrl.clear(); + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Catatan klinis SOAP berhasil disimpan.'), + backgroundColor: AppColors.verifiedGreen, + ), + ); + } + + @override + Widget build(BuildContext context) { + final patientAsync = ref.watch(patientDetailProvider(widget.patientId)); + final notesAsync = ref.watch(patientNotesProvider(widget.patientId)); + + return Scaffold( + backgroundColor: const Color(0xFFF8F9FE), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + centerTitle: false, + title: const Text( + 'Rekam Medis Pasien', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ), + body: patientAsync.when( + data: (patient) { + return SafeArea( + child: Column( + children: [ + // Soft Patient Header Card (No harsh borders) + Container( + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 26, + backgroundColor: patient.isHighRisk + ? AppColors.highRiskBg + : AppColors.primaryContainer, + child: Text( + patient.name.substring(0, 1), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: patient.isHighRisk + ? AppColors.highRiskRed + : AppColors.primary, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + patient.name, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 2), + Text( + '${patient.age} tahun â€ĸ ${patient.gender}', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + patient.hasConsent + ? StatusBadge.consentGranted('Akses Emosional Terbuka') + : StatusBadge.consentLocked('Akses Terkunci'), + if (patient.upcomingSchedule != null) + patient.upcomingSchedule!.isLiveNow + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFDCFCE7), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Color(0xFF16A34A), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + 'Live: ${patient.upcomingSchedule!.timeRange}', + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Color(0xFF15803D), + ), + ), + ], + ), + ) + : Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.schedule_rounded, size: 11, color: Color(0xFF2563EB)), + const SizedBox(width: 4), + Text( + patient.upcomingSchedule!.formattedTime, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Color(0xFF1D4ED8), + ), + ), + ], + ), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Belum Ada Jadwal', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Color(0xFF64748B), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Keluhan Utama:', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + patient.chiefComplaint, + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, height: 1.4), + ), + ], + ), + ), + ], + ), + ), + + // Modern Pill-Segmented Switcher (Custom Soft Tab Bar) + Container( + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEBEFF7), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => setState(() => _activeTabIndex = 0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: _activeTabIndex == 0 ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(12), + boxShadow: _activeTabIndex == 0 + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Text( + 'Rekam Emosional', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: _activeTabIndex == 0 ? FontWeight.bold : FontWeight.w500, + color: _activeTabIndex == 0 ? AppColors.primary : AppColors.textSecondary, + ), + ), + ), + ), + ), + Expanded( + child: GestureDetector( + onTap: () => setState(() => _activeTabIndex = 1), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: _activeTabIndex == 1 ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(12), + boxShadow: _activeTabIndex == 1 + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Text( + 'Catatan SOAP (Luna AI)', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: _activeTabIndex == 1 ? FontWeight.bold : FontWeight.w500, + color: _activeTabIndex == 1 ? AppColors.primary : AppColors.textSecondary, + ), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + + // Tab Content Area + Expanded( + child: _activeTabIndex == 0 + ? SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: patient.hasConsent + ? _buildConsentGrantedContent(patient) + : _buildConsentLockedGuard(patient), + ) + : SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: _buildSoapTabContent(patient, notesAsync), + ), + ), + ], + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + ), + ); + } + + // TAB 1: LOCKED CONSENT VIEW + Widget _buildConsentLockedGuard(dynamic patient) { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.shield_outlined, + size: 40, + color: AppColors.primary, + ), + ), + const SizedBox(height: 16), + const Text( + 'Rekam Emosional Terlindungi', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Berdasarkan regulasi privasi kesehatan mental, riwayat skrining DASS-21 dan ringkasan jurnal emosional harian Luna AI hanya dapat diakses setelah pasien memberikan persetujuan (Consent).', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary, height: 1.5), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + CustomButton( + label: patient.consentRequestedAt != null + ? 'Permintaan Terkirim (Kirim Ulang)' + : 'Minta Izin Akses Pasien', + icon: Icons.send_rounded, + isLoading: _isRequestingConsent, + onPressed: _handleRequestConsent, + ), + ], + ), + ); + } + + // TAB 1: GRANTED CONSENT VIEW + Widget _buildConsentGrantedContent(dynamic patient) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppColors.verifiedBg, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: const [ + Icon(Icons.verified_rounded, color: AppColors.verifiedGreen, size: 16), + SizedBox(width: 8), + Expanded( + child: Text( + 'Persetujuan Aktif â€ĸ Akses Rekam Terbuka Resmi', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: AppColors.verifiedGreen), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + const Text('Hasil Skrining Klinis (DASS-21)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 10), + if (patient.dassHistory != null && patient.dassHistory!.isNotEmpty) + ...patient.dassHistory!.map((dass) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Evaluasi: ${dass.date.day}/${dass.date.month}/${dass.date.year}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + const SizedBox(width: 8), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: StatusBadge.verified(dass.severityLabel), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + _buildDassPill('Depresi', dass.depressionScore, AppColors.primary), + const SizedBox(width: 8), + _buildDassPill('Kecemasan', dass.anxietyScore, AppColors.warning), + const SizedBox(width: 8), + _buildDassPill('Stres', dass.stressScore, AppColors.error), + ], + ), + ], + ), + ); + }).toList(), + + const SizedBox(height: 16), + const Text('Ringkasan Jurnal Emosi Luna AI (Dibagikan Pasien)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 10), + if (patient.sharedDiarySummaries != null) + ...patient.sharedDiarySummaries!.map((s) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.auto_awesome, color: AppColors.primary, size: 16), + const SizedBox(width: 10), + Expanded( + child: Text( + s, + style: const TextStyle(fontSize: 12, color: AppColors.textPrimary, height: 1.4), + ), + ), + ], + ), + ); + }).toList(), + ], + ); + } + + // TAB 2: SOAP TAB CONTENT + Widget _buildSoapTabContent(dynamic patient, AsyncValue> notesAsync) { + final schedule = patient.upcomingSchedule; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!_sessionUnlocked) ...[ + _buildScheduleGatedSoapCard(patient, schedule), + ] else ...[ + // UNLOCKED: LUNA DRAFT BANNER + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF1F3FD), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Icon(Icons.auto_awesome, color: AppColors.primary, size: 20), + SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Draf Transkripsi Otomatis AI Luna', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.primary), + ), + SizedBox(height: 2), + Text( + 'Data di bawah telah disintesis dari percakapan sesi video. Anda dapat mengedit dan menyempurnakannya sebelum finalisasi.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.4), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 14), + + // SOAP INPUTS (Soft, No Harsh Outlines) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSoftInput('S (Subjective) - Keluhan Pasien', _subjCtrl), + const SizedBox(height: 12), + _buildSoftInput('O (Objective) - Observasi Afek & Intonasi', _objCtrl), + const SizedBox(height: 12), + _buildSoftInput('A (Assessment) - Analisis Klinis Psikolog', _assessCtrl), + const SizedBox(height: 12), + _buildSoftInput('P (Plan) - Rencana Terapi & PR Pasien', _planCtrl), + const SizedBox(height: 16), + CustomButton( + label: 'Simpan & Finalisasi Rekam Medis', + icon: Icons.check_circle_outline_rounded, + isLoading: _isSavingNote, + onPressed: _handleSaveSoapNote, + ), + ], + ), + ), + ], + + const SizedBox(height: 20), + const Text('Riwayat Catatan Konseling Lalu', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 10), + notesAsync.when( + data: (notes) { + if (notes.isEmpty) { + return const Text('Belum ada catatan terdokumentasi.', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)); + } + return Column( + children: notes.map((note) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Sesi: ${note.createdAt.day}/${note.createdAt.month}/${note.createdAt.year}', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + StatusBadge.verified('Terdokumentasi'), + ], + ), + const SizedBox(height: 8), + _buildSoapSummaryItem('S', note.subjective), + _buildSoapSummaryItem('O', note.objective), + _buildSoapSummaryItem('A', note.assessment), + _buildSoapSummaryItem('P', note.plan), + ], + ), + ); + }).toList(), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error: $e'), + ), + ], + ); + } + + Widget _buildSoftInput(String label, TextEditingController controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.textPrimary)), + const SizedBox(height: 6), + Container( + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(12), + ), + child: TextField( + controller: controller, + maxLines: 3, + style: const TextStyle(fontSize: 12), + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.all(12), + ), + ), + ), + ], + ); + } + + Widget _buildSoapSummaryItem(String tag, String text) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text(tag, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.primary)), + ), + const SizedBox(width: 8), + Expanded( + child: Text(text, style: const TextStyle(fontSize: 11, color: AppColors.textSecondary)), + ), + ], + ), + ); + } + + Widget _buildDassPill(String label, int score, Color color) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + Text(label, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: color)), + const SizedBox(height: 2), + Text( + '$score', + style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16, color: color), + ), + ], + ), + ), + ); + } + + Widget _buildScheduleGatedSoapCard(dynamic patient, dynamic schedule) { + if (schedule != null && schedule.isLiveNow) { + // 1. SESI AKTIF / SIAP DIMULAI (Ruang VC Dibuka) + return Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: const Color(0xFFDCFCE7)), + boxShadow: [ + BoxShadow( + color: const Color(0xFF16A34A).withValues(alpha: 0.06), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: const BoxDecoration( + color: Color(0xFFDCFCE7), + shape: BoxShape.circle, + ), + child: const Icon(Icons.videocam_rounded, size: 36, color: Color(0xFF15803D)), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFF16A34A), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + const Text( + 'Sesi Telekonsultasi Siap Dimulai', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.textPrimary), + ), + ], + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF0FDF4), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFBBF7D0)), + ), + child: Text( + 'Jadwal Sesi: ${schedule.formattedTime}', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Color(0xFF15803D)), + ), + ), + const SizedBox(height: 8), + const Text( + 'Waktu jadwal telekonsultasi telah aktif. Anda dapat langsung membuka ruang video call sekarang. AI Luna akan mentranskripsikan audio percakapan menjadi draf catatan SOAP secara otomatis.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: AppColors.textSecondary, height: 1.5), + ), + const SizedBox(height: 18), + CustomButton( + label: 'Masuk Ruang Video Call Sekarang', + icon: Icons.videocam_rounded, + onPressed: () async { + final finished = await Navigator.of(context).pushNamed( + '/counselor-video-call', + arguments: schedule.sessionId ?? 'session-live-001', + ); + if (finished == true && mounted) { + _populateLunaTranscriptDraft(patient); + } + }, + ), + const SizedBox(height: 10), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFD0D4E5)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ), + icon: const Icon(Icons.auto_awesome, color: AppColors.primary, size: 16), + label: const Text( + 'Simulasikan Draf Transkrip AI Luna', + style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 12), + ), + onPressed: () => _populateLunaTranscriptDraft(patient), + ), + ], + ), + ); + } else if (schedule != null && !schedule.isLiveNow) { + // 2. JADWAL MENDATANG (Ruang VC Belum Waktunya Dibuka) + return Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: const BoxDecoration( + color: Color(0xFFFEF3C7), + shape: BoxShape.circle, + ), + child: const Icon(Icons.schedule_rounded, size: 36, color: Color(0xFFD97706)), + ), + const SizedBox(height: 12), + const Text( + 'Catatan SOAP Klinis Terkunci', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.textPrimary), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FE), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.event_note_rounded, size: 15, color: AppColors.primary), + const SizedBox(width: 6), + Text( + schedule.formattedTime, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.textPrimary), + ), + ], + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFFDE68A)), + ), + child: Text( + 'âąī¸ ${schedule.statusText}', + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Color(0xFFB45309)), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Text( + 'Sesuai etika telekonsultasi klinis, ruang video call dan transkripsi SOAP baru dibuka 15 menit sebelum sesi (pukul ${schedule.roomOpensAt}). Pasien belum standby di ruang sesi saat ini.', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, height: 1.5), + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.lock_outline_rounded, size: 16, color: Color(0xFF94A3B8)), + const SizedBox(width: 8), + Text( + 'Ruang VC Dibuka Pukul ${schedule.roomOpensAt}', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Color(0xFF94A3B8)), + ), + ], + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFD0D4E5)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + icon: const Icon(Icons.play_circle_outline_rounded, color: AppColors.textSecondary, size: 15), + label: const Text( + 'Buka VC (Demo)', + style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.bold, fontSize: 11), + ), + onPressed: () async { + final finished = await Navigator.of(context).pushNamed( + '/counselor-video-call', + arguments: schedule.sessionId ?? 'session-live-001', + ); + if (finished == true && mounted) { + _populateLunaTranscriptDraft(patient); + } + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFD0D4E5)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + icon: const Icon(Icons.auto_awesome, color: AppColors.primary, size: 15), + label: const Text( + 'Draf AI (Demo)', + style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 11), + ), + onPressed: () => _populateLunaTranscriptDraft(patient), + ), + ), + ], + ), + ], + ), + ); + } else { + // 3. BELUM ADA JADWAL AKTIF MENDATANG + return Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: const BoxDecoration( + color: Color(0xFFF1F5F9), + shape: BoxShape.circle, + ), + child: const Icon(Icons.event_busy_rounded, size: 36, color: Color(0xFF64748B)), + ), + const SizedBox(height: 12), + const Text( + 'Belum Ada Jadwal Konsultasi', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.textPrimary), + ), + const SizedBox(height: 4), + Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'Menunggu Reservasi Sesi Baru', + style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Color(0xFF64748B)), + ), + ), + const SizedBox(height: 8), + const Text( + 'Pasien ini belum memiliki jadwal telekonsultasi aktif. Formulir SOAP klinis baru akan terbuka setelah sesi telekonsultasi terkonfirmasi dan terlaksana.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: AppColors.textSecondary, height: 1.5), + ), + const SizedBox(height: 16), + CustomButton( + label: 'Tawarkan Jadwal Konseling', + icon: Icons.calendar_month_rounded, + onPressed: () => _showOfferScheduleSheet(context, patient), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFD0D4E5)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + ), + icon: const Icon(Icons.auto_awesome, color: AppColors.primary, size: 16), + label: const Text( + 'Simulasikan Draf Transkrip AI Luna (Demo)', + style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 12), + ), + onPressed: () => _populateLunaTranscriptDraft(patient), + ), + ], + ), + ); + } + } + + void _showOfferScheduleSheet(BuildContext context, dynamic patient) { + int selectedSlot = 0; + final slots = [ + 'Besok Rabu, 12 Nov â€ĸ 10:00 - 10:50 WIB', + 'Kamis, 13 Nov â€ĸ 14:00 - 14:50 WIB', + 'Jumat, 14 Nov â€ĸ 16:00 - 16:50 WIB', + 'Senin, 17 Nov â€ĸ 11:00 - 11:50 WIB', + ]; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (ctx) => StatefulBuilder( + builder: (context, setModalState) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.event_available_rounded, color: AppColors.primary, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Tawarkan Jadwal Konseling', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.textPrimary), + ), + Text( + 'Pilih slot praktik untuk diajukan ke ${patient.name}', + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + const Text( + 'Pilihan Slot Tersedia:', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.textPrimary), + ), + const SizedBox(height: 8), + ...List.generate(slots.length, (idx) { + final isSel = selectedSlot == idx; + return GestureDetector( + onTap: () => setModalState(() => selectedSlot = idx), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: isSel ? AppColors.primaryContainer.withValues(alpha: 0.3) : const Color(0xFFF8F9FE), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSel ? AppColors.primary : const Color(0xFFECEFF8), + ), + ), + child: Row( + children: [ + Icon( + isSel ? Icons.radio_button_checked_rounded : Icons.radio_button_off_rounded, + size: 18, + color: isSel ? AppColors.primary : AppColors.textLight, + ), + const SizedBox(width: 10), + Text( + slots[idx], + style: TextStyle( + fontSize: 12, + fontWeight: isSel ? FontWeight.bold : FontWeight.w500, + color: isSel ? AppColors.primary : AppColors.textPrimary, + ), + ), + ], + ), + ), + ); + }), + const SizedBox(height: 14), + CustomButton( + label: 'Kirim Tawaran Jadwal ke Pasien', + icon: Icons.send_rounded, + onPressed: () { + Navigator.of(ctx).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('✨ Tawaran jadwal ${slots[selectedSlot]} berhasil dikirim ke ${patient.name}.'), + backgroundColor: AppColors.primary, + ), + ); + }, + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/patients/presentation/screens/patient_list_screen.dart b/apps/eva_counselor_mobile/lib/features/patients/presentation/screens/patient_list_screen.dart new file mode 100644 index 0000000..711c3ac --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/patients/presentation/screens/patient_list_screen.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../providers/patient_provider.dart'; + +class PatientListScreen extends ConsumerStatefulWidget { + const PatientListScreen({super.key}); + + @override + ConsumerState createState() => _PatientListScreenState(); +} + +class _PatientListScreenState extends ConsumerState { + String _selectedFilter = 'Semua'; + String _searchQuery = ''; + + @override + Widget build(BuildContext context) { + final patientsAsync = ref.watch(patientsListProvider); + + return Scaffold( + backgroundColor: const Color(0xFFF8F9FE), + body: SafeArea( + child: Column( + children: [ + // Soft Search Box (Clean edge-to-edge layout) + Container( + color: Colors.white, + padding: const EdgeInsets.only(left: 20, right: 20, top: 16, bottom: 10), + child: Container( + decoration: BoxDecoration( + color: const Color(0xFFF4F6FB), + borderRadius: BorderRadius.circular(16), + ), + child: TextField( + decoration: const InputDecoration( + hintText: 'Cari nama pasien, usia, atau keluhan...', + hintStyle: TextStyle(fontSize: 13, color: AppColors.textLight), + prefixIcon: Icon(Icons.search_rounded, color: AppColors.primary, size: 20), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 13, horizontal: 16), + ), + onChanged: (val) { + setState(() => _searchQuery = val.toLowerCase()); + }, + ), + ), + ), + + // Soft Pill Filter Bar + Container( + color: Colors.white, + padding: const EdgeInsets.only(left: 20, right: 20, bottom: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: ['Semua', 'Akses Terbuka', 'Akses Terkunci', 'Krisis Darurat'].map((filter) { + final isSelected = _selectedFilter == filter; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => setState(() => _selectedFilter = filter), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : const Color(0xFFF4F6FB), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + filter, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 8), + + // Patient Cards List + Expanded( + child: patientsAsync.when( + data: (patients) { + final filtered = patients.where((p) { + final matchesSearch = p.name.toLowerCase().contains(_searchQuery) || + p.chiefComplaint.toLowerCase().contains(_searchQuery); + if (!matchesSearch) return false; + + if (_selectedFilter == 'Akses Terbuka') return p.hasConsent; + if (_selectedFilter == 'Akses Terkunci') return !p.hasConsent; + if (_selectedFilter == 'Krisis Darurat') return p.isHighRisk; + return true; + }).toList(); + + if (filtered.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.person_search_rounded, size: 48, color: AppColors.textLight), + SizedBox(height: 10), + Text('Tidak ada pasien yang sesuai filter.', style: TextStyle(color: AppColors.textSecondary)), + ], + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + itemCount: filtered.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final p = filtered[index]; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: p.isHighRisk + ? AppColors.highRiskRed.withValues(alpha: 0.2) + : const Color(0xFFECEFF8), + ), + boxShadow: [ + BoxShadow( + color: p.isHighRisk + ? AppColors.highRiskRed.withValues(alpha: 0.04) + : Colors.black.withValues(alpha: 0.02), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + Navigator.of(context).pushNamed( + '/counselor-patient-detail', + arguments: p.id, + ); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: p.isHighRisk + ? AppColors.highRiskBg + : AppColors.primaryContainer, + child: Text( + p.name.substring(0, 1), + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 16, + color: p.isHighRisk + ? AppColors.highRiskRed + : AppColors.primary, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${p.name}, ${p.age} thn', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 2), + Text( + '${p.totalCompletedSessions} Sesi Selesai â€ĸ ${p.gender}', + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + ], + ), + ), + p.hasConsent + ? StatusBadge.consentGranted('Akses Aktif') + : StatusBadge.consentLocked('Terkunci'), + ], + ), + const SizedBox(height: 12), + + // Chief Complaint Box (Soft light background) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.chat_bubble_outline_rounded, + size: 14, color: AppColors.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + p.chiefComplaint, + style: const TextStyle( + fontSize: 12, + color: AppColors.textPrimary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + if (p.isHighRisk) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.highRiskBg, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.crisis_alert_rounded, size: 14, color: AppColors.highRiskRed), + SizedBox(width: 6), + Text( + 'Peringatan: Indikasi Krisis Emosional Akut', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: AppColors.highRiskRed, + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/video_call/data/datasources/mock_video_call_datasource.dart b/apps/eva_counselor_mobile/lib/features/video_call/data/datasources/mock_video_call_datasource.dart new file mode 100644 index 0000000..acd5ace --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/video_call/data/datasources/mock_video_call_datasource.dart @@ -0,0 +1,31 @@ +import '../models/video_session_dto.dart'; +import 'video_call_datasource.dart'; + +class MockVideoCallDataSource implements VideoCallDataSource { + @override + Future fetchSessionDetails(String sessionId) async { + await Future.delayed(const Duration(milliseconds: 300)); + return VideoSessionResponseDto( + sessionId: sessionId, + patientId: 'patient-001', + patientName: 'Rian Pratama', + scheduledTime: DateTime.now().toIso8601String(), + status: 'ready', + signalingToken: 'mock-webrtc-token-12345', + channelName: 'counseling-room-$sessionId', + ); + } + + @override + Future initiateCall(String sessionId) async { + await Future.delayed(const Duration(milliseconds: 400)); + return true; + } + + @override + Future terminateCall( + String sessionId, int durationSeconds, List notes) async { + await Future.delayed(const Duration(milliseconds: 400)); + return true; + } +} diff --git a/apps/eva_counselor_mobile/lib/features/video_call/data/datasources/remote_video_call_datasource.dart b/apps/eva_counselor_mobile/lib/features/video_call/data/datasources/remote_video_call_datasource.dart new file mode 100644 index 0000000..062bb85 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/video_call/data/datasources/remote_video_call_datasource.dart @@ -0,0 +1,36 @@ +import '../../../../core/network/api_client.dart'; +import '../../../../core/network/api_endpoints.dart'; +import '../models/video_session_dto.dart'; +import 'video_call_datasource.dart'; + +class RemoteVideoCallDataSource implements VideoCallDataSource { + final ApiClient _client; + + RemoteVideoCallDataSource({ApiClient? client}) + : _client = client ?? ApiClient(); + + @override + Future fetchSessionDetails(String sessionId) async { + final response = await _client.get(ApiEndpoints.sessionDetail(sessionId)); + return VideoSessionResponseDto.fromJson(response as Map); + } + + @override + Future initiateCall(String sessionId) async { + final response = await _client.post(ApiEndpoints.startSession(sessionId)); + return (response as Map?)?['success'] as bool? ?? true; + } + + @override + Future terminateCall( + String sessionId, int durationSeconds, List notes) async { + final response = await _client.post( + ApiEndpoints.endSession(sessionId), + body: { + 'duration_seconds': durationSeconds, + 'quick_notes': notes, + }, + ); + return (response as Map?)?['success'] as bool? ?? true; + } +} diff --git a/apps/eva_counselor_mobile/lib/features/video_call/data/datasources/video_call_datasource.dart b/apps/eva_counselor_mobile/lib/features/video_call/data/datasources/video_call_datasource.dart new file mode 100644 index 0000000..1527723 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/video_call/data/datasources/video_call_datasource.dart @@ -0,0 +1,7 @@ +import '../models/video_session_dto.dart'; + +abstract class VideoCallDataSource { + Future fetchSessionDetails(String sessionId); + Future initiateCall(String sessionId); + Future terminateCall(String sessionId, int durationSeconds, List notes); +} diff --git a/apps/eva_counselor_mobile/lib/features/video_call/data/models/video_session_dto.dart b/apps/eva_counselor_mobile/lib/features/video_call/data/models/video_session_dto.dart new file mode 100644 index 0000000..f8db3a2 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/video_call/data/models/video_session_dto.dart @@ -0,0 +1,54 @@ +import '../../domain/entities/video_call_state_entity.dart'; + +class VideoSessionResponseDto { + final String sessionId; + final String patientId; + final String patientName; + final String scheduledTime; + final String status; + final String? signalingToken; + final String? channelName; + + const VideoSessionResponseDto({ + required this.sessionId, + required this.patientId, + required this.patientName, + required this.scheduledTime, + required this.status, + this.signalingToken, + this.channelName, + }); + + factory VideoSessionResponseDto.fromJson(Map json) { + return VideoSessionResponseDto( + sessionId: json['session_id']?.toString() ?? '', + patientId: json['patient_id']?.toString() ?? '', + patientName: json['patient_name']?.toString() ?? 'Pasien', + scheduledTime: json['scheduled_time']?.toString() ?? + DateTime.now().toIso8601String(), + status: json['status']?.toString() ?? 'ready', + signalingToken: json['signaling_token']?.toString(), + channelName: json['channel_name']?.toString(), + ); + } + + Map toJson() => { + 'session_id': sessionId, + 'patient_id': patientId, + 'patient_name': patientName, + 'scheduled_time': scheduledTime, + 'status': status, + 'signaling_token': signalingToken, + 'channel_name': channelName, + }; + + VideoCallSessionEntity toEntity() { + return VideoCallSessionEntity( + sessionId: sessionId, + patientId: patientId, + patientName: patientName, + scheduledTime: DateTime.tryParse(scheduledTime) ?? DateTime.now(), + status: VideoCallStatus.idle, + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/video_call/data/repositories/video_call_repository_impl.dart b/apps/eva_counselor_mobile/lib/features/video_call/data/repositories/video_call_repository_impl.dart new file mode 100644 index 0000000..2df5c8f --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/video_call/data/repositories/video_call_repository_impl.dart @@ -0,0 +1,27 @@ +import '../../domain/entities/video_call_state_entity.dart'; +import '../../domain/repositories/video_call_repository.dart'; +import '../datasources/video_call_datasource.dart'; + +class VideoCallRepositoryImpl implements IVideoCallRepository { + final VideoCallDataSource _dataSource; + + VideoCallRepositoryImpl({required VideoCallDataSource dataSource}) + : _dataSource = dataSource; + + @override + Future getSessionDetails(String sessionId) async { + final dto = await _dataSource.fetchSessionDetails(sessionId); + return dto.toEntity(); + } + + @override + Future startSession(String sessionId) async { + return await _dataSource.initiateCall(sessionId); + } + + @override + Future endSession( + String sessionId, Duration duration, List notes) async { + return await _dataSource.terminateCall(sessionId, duration.inSeconds, notes); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/video_call/domain/entities/video_call_state_entity.dart b/apps/eva_counselor_mobile/lib/features/video_call/domain/entities/video_call_state_entity.dart new file mode 100644 index 0000000..d53495a --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/video_call/domain/entities/video_call_state_entity.dart @@ -0,0 +1,60 @@ +enum VideoCallStatus { + idle, + connecting, + connected, + reconnecting, + ended, + error, +} + +class VideoCallSessionEntity { + final String sessionId; + final String patientId; + final String patientName; + final DateTime scheduledTime; + final Duration callDuration; + final VideoCallStatus status; + final bool isMicMuted; + final bool isVideoDisabled; + final bool isFrontCamera; + final List realTimeNotes; + + const VideoCallSessionEntity({ + required this.sessionId, + required this.patientId, + required this.patientName, + required this.scheduledTime, + this.callDuration = Duration.zero, + this.status = VideoCallStatus.idle, + this.isMicMuted = false, + this.isVideoDisabled = false, + this.isFrontCamera = true, + this.realTimeNotes = const [], + }); + + VideoCallSessionEntity copyWith({ + String? sessionId, + String? patientId, + String? patientName, + DateTime? scheduledTime, + Duration? callDuration, + VideoCallStatus? status, + bool? isMicMuted, + bool? isVideoDisabled, + bool? isFrontCamera, + List? realTimeNotes, + }) { + return VideoCallSessionEntity( + sessionId: sessionId ?? this.sessionId, + patientId: patientId ?? this.patientId, + patientName: patientName ?? this.patientName, + scheduledTime: scheduledTime ?? this.scheduledTime, + callDuration: callDuration ?? this.callDuration, + status: status ?? this.status, + isMicMuted: isMicMuted ?? this.isMicMuted, + isVideoDisabled: isVideoDisabled ?? this.isVideoDisabled, + isFrontCamera: isFrontCamera ?? this.isFrontCamera, + realTimeNotes: realTimeNotes ?? this.realTimeNotes, + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/video_call/domain/repositories/video_call_repository.dart b/apps/eva_counselor_mobile/lib/features/video_call/domain/repositories/video_call_repository.dart new file mode 100644 index 0000000..bc51acb --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/video_call/domain/repositories/video_call_repository.dart @@ -0,0 +1,7 @@ +import '../entities/video_call_state_entity.dart'; + +abstract class IVideoCallRepository { + Future getSessionDetails(String sessionId); + Future startSession(String sessionId); + Future endSession(String sessionId, Duration duration, List notes); +} diff --git a/apps/eva_counselor_mobile/lib/features/video_call/presentation/controllers/video_call_controller.dart b/apps/eva_counselor_mobile/lib/features/video_call/presentation/controllers/video_call_controller.dart new file mode 100644 index 0000000..7028994 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/video_call/presentation/controllers/video_call_controller.dart @@ -0,0 +1,59 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../domain/entities/video_call_state_entity.dart'; +import '../../domain/repositories/video_call_repository.dart'; + +class VideoCallController extends StateNotifier { + final IVideoCallRepository _repository; + Timer? _durationTimer; + + VideoCallController(this._repository, VideoCallSessionEntity initial) + : super(initial); + + void startCall() async { + state = state.copyWith(status: VideoCallStatus.connecting); + await _repository.startSession(state.sessionId); + state = state.copyWith(status: VideoCallStatus.connected); + + _durationTimer?.cancel(); + _durationTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + state = state.copyWith( + callDuration: Duration(seconds: state.callDuration.inSeconds + 1), + ); + }); + } + + void toggleMic() { + state = state.copyWith(isMicMuted: !state.isMicMuted); + } + + void toggleVideo() { + state = state.copyWith(isVideoDisabled: !state.isVideoDisabled); + } + + void flipCamera() { + state = state.copyWith(isFrontCamera: !state.isFrontCamera); + } + + void addQuickNote(String note) { + if (note.trim().isEmpty) return; + final updated = List.from(state.realTimeNotes)..add(note.trim()); + state = state.copyWith(realTimeNotes: updated); + } + + void endCall() async { + _durationTimer?.cancel(); + state = state.copyWith(status: VideoCallStatus.ended); + await _repository.endSession( + state.sessionId, + state.callDuration, + state.realTimeNotes, + ); + } + + @override + void dispose() { + _durationTimer?.cancel(); + super.dispose(); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/video_call/presentation/providers/video_call_provider.dart b/apps/eva_counselor_mobile/lib/features/video_call/presentation/providers/video_call_provider.dart new file mode 100644 index 0000000..e35b7b4 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/video_call/presentation/providers/video_call_provider.dart @@ -0,0 +1,29 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/config/app_config.dart'; +import '../../domain/entities/video_call_state_entity.dart'; +import '../../domain/repositories/video_call_repository.dart'; +import '../../data/datasources/video_call_datasource.dart'; +import '../../data/datasources/mock_video_call_datasource.dart'; +import '../../data/datasources/remote_video_call_datasource.dart'; +import '../../data/repositories/video_call_repository_impl.dart'; +import '../controllers/video_call_controller.dart'; + +final videoCallDataSourceProvider = Provider((ref) { + if (AppConfig.useMockData) { + return MockVideoCallDataSource(); + } + return RemoteVideoCallDataSource(); +}); + +final videoCallRepositoryProvider = Provider((ref) { + final ds = ref.watch(videoCallDataSourceProvider); + return VideoCallRepositoryImpl(dataSource: ds); +}); + +final videoCallControllerProvider = StateNotifierProvider.family< + VideoCallController, VideoCallSessionEntity, VideoCallSessionEntity>( + (ref, initialSession) { + final repo = ref.watch(videoCallRepositoryProvider); + return VideoCallController(repo, initialSession); + }, +); diff --git a/apps/eva_counselor_mobile/lib/features/video_call/presentation/screens/video_consultation_screen.dart b/apps/eva_counselor_mobile/lib/features/video_call/presentation/screens/video_consultation_screen.dart new file mode 100644 index 0000000..17a7eec --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/video_call/presentation/screens/video_consultation_screen.dart @@ -0,0 +1,530 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/custom_text_field.dart'; +import '../../../../core/widgets/top_toast.dart'; +import '../../domain/entities/video_call_state_entity.dart'; +import '../providers/video_call_provider.dart'; + +class VideoConsultationScreen extends ConsumerStatefulWidget { + final String sessionId; + + const VideoConsultationScreen({super.key, required this.sessionId}); + + @override + ConsumerState createState() => + _VideoConsultationScreenState(); +} + +class _VideoConsultationScreenState + extends ConsumerState { + final _quickNoteCtrl = TextEditingController(); + late final VideoCallSessionEntity _initialSession; + + bool _isPatientPresent = false; + Timer? _simulatedJoinTimer; + + @override + void initState() { + super.initState(); + _initialSession = VideoCallSessionEntity( + sessionId: widget.sessionId, + patientId: 'patient-001', + patientName: 'Rian Pratama', + scheduledTime: DateTime.now(), + ); + + // Otomatis simulasi pasien masuk setelah 3.5 detik jika tidak diklik manual + _simulatedJoinTimer = Timer(const Duration(milliseconds: 3500), () { + if (mounted && !_isPatientPresent) { + _connectPatient(); + } + }); + } + + void _connectPatient() { + _simulatedJoinTimer?.cancel(); + setState(() => _isPatientPresent = true); + ref.read(videoCallControllerProvider(_initialSession).notifier).startCall(); + TopToast.show( + context, + title: 'Pasien Telah Bergabung', + message: 'Rian Pratama telah terhubung. Penghitung waktu sesi 50 menit resmi dimulai.', + type: ToastType.success, + ); + } + + @override + void dispose() { + _simulatedJoinTimer?.cancel(); + super.dispose(); + } + + void _showQuickNotesDrawer(BuildContext context, dynamic session) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Catatan Cepat Selama Sesi', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: CustomTextField( + label: '', + hint: 'Tulis observasi afek atau poin penting...', + controller: _quickNoteCtrl, + ), + ), + const SizedBox(width: 8), + IconButton( + style: IconButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + icon: const Icon(Icons.add), + onPressed: () { + if (_quickNoteCtrl.text.isNotEmpty) { + ref + .read(videoCallControllerProvider(_initialSession).notifier) + .addQuickNote(_quickNoteCtrl.text); + _quickNoteCtrl.clear(); + } + }, + ), + ], + ), + const SizedBox(height: 16), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 180), + child: session.realTimeNotes.isEmpty + ? const Text('Belum ada catatan observasi ditambahkan.', style: TextStyle(color: AppColors.textSecondary, fontSize: 12)) + : ListView.builder( + shrinkWrap: true, + itemCount: session.realTimeNotes.length, + itemBuilder: (context, idx) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.check_circle_rounded, + size: 16, color: AppColors.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + session.realTimeNotes[idx] as String, + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + String _formatDuration(Duration d) { + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final session = ref.watch(videoCallControllerProvider(_initialSession)); + + return Scaffold( + backgroundColor: const Color(0xFF101318), + body: SafeArea( + child: Stack( + children: [ + // 1. Patient Video View / Waiting Canvas + Positioned.fill( + child: Container( + color: const Color(0xFF161A24), + child: Center( + child: !_isPatientPresent + ? _buildWaitingPatientView(session) + : _buildConnectedPatientView(session), + ), + ), + ), + + // 2. Picture-in-Picture Counselor Self View (Top Right) + Positioned( + top: 20, + right: 20, + child: Container( + width: 100, + height: 135, + decoration: BoxDecoration( + color: const Color(0xFF282D3D), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withValues(alpha: 0.2), width: 1.5), + boxShadow: const [ + BoxShadow( + color: Colors.black45, + blurRadius: 12, + offset: Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + Container( + color: const Color(0xFF2B3245), + child: Center( + child: session.isVideoDisabled + ? const Icon(Icons.videocam_off, color: Colors.white54, size: 24) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.person, color: Colors.white, size: 30), + SizedBox(height: 4), + Text('Anda (Psikolog)', style: TextStyle(color: Colors.white70, fontSize: 10)), + ], + ), + ), + ), + if (session.isMicMuted) + Positioned( + bottom: 6, + right: 6, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: AppColors.error, + shape: BoxShape.circle, + ), + child: const Icon(Icons.mic_off, color: Colors.white, size: 10), + ), + ), + ], + ), + ), + ), + ), + + // 3. Top Call Status HUD & Timer Bar + Positioned( + top: 20, + left: 20, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + style: IconButton.styleFrom( + backgroundColor: Colors.white.withValues(alpha: 0.15), + foregroundColor: Colors.white, + ), + icon: const Icon(Icons.arrow_back, size: 18), + onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white.withValues(alpha: 0.15), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + icon: const Icon(Icons.edit_note_rounded, size: 16), + label: Text('Catatan (${session.realTimeNotes.length})', style: const TextStyle(fontSize: 12)), + onPressed: () => _showQuickNotesDrawer(context, session), + ), + ], + ), + const SizedBox(height: 8), + + // TIMER BADGE (MENUNGGU vs AKTIF) + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _isPatientPresent + ? AppColors.verifiedGreen.withValues(alpha: 0.85) + : AppColors.pendingAmber.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + _isPatientPresent + ? 'Sesi Aktif: ${_formatDuration(session.callDuration)} / 50:00' + : 'Menunggu Pasien Masuk...', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + ), + + // 4. Bottom Control Bar (Mute, Camera, Flip, End) + Positioned( + bottom: 24, + left: 20, + right: 20, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xDD1E2333), + borderRadius: BorderRadius.circular(32), + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Mic Toggle + _buildRoundCallButton( + icon: session.isMicMuted ? Icons.mic_off : Icons.mic, + isActive: !session.isMicMuted, + onTap: () { + ref + .read(videoCallControllerProvider(_initialSession).notifier) + .toggleMic(); + }, + ), + + // Video Camera Toggle + _buildRoundCallButton( + icon: session.isVideoDisabled + ? Icons.videocam_off + : Icons.videocam, + isActive: !session.isVideoDisabled, + onTap: () { + ref + .read(videoCallControllerProvider(_initialSession).notifier) + .toggleVideo(); + }, + ), + + // Flip Camera + _buildRoundCallButton( + icon: Icons.flip_camera_ios, + isActive: false, + onTap: () { + ref + .read(videoCallControllerProvider(_initialSession).notifier) + .flipCamera(); + }, + ), + + // End Call (Red) + GestureDetector( + onTap: () { + ref + .read(videoCallControllerProvider(_initialSession).notifier) + .endCall(); + Navigator.of(context).pop(true); + }, + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: AppColors.error, + shape: BoxShape.circle, + ), + child: const Icon(Icons.call_end, color: Colors.white, size: 26), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + // WAITING STATE WIDGET (BELUM HADIR) + Widget _buildWaitingPatientView(VideoCallSessionEntity session) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primary.withValues(alpha: 0.15), + ), + ), + Container( + width: 90, + height: 90, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.pendingAmber, width: 2), + color: AppColors.primary.withValues(alpha: 0.3), + ), + child: Center( + child: Text( + session.patientName.substring(0, 1), + style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Menunggu ${session.patientName} Hadir...', + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 6), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 40), + child: Text( + 'Ruang telekonsultasi aktif. Penghitung durasi 50 menit baru akan berjalan saat pasien resmi masuk ke dalam panggilan.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white60, fontSize: 12, height: 1.4), + ), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + icon: const Icon(Icons.person_add_rounded, size: 16), + label: const Text('Simulasikan Pasien Masuk', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + onPressed: _connectPatient, + ), + ], + ); + } + + // CONNECTED STATE WIDGET (SUDAH HADIR) + Widget _buildConnectedPatientView(VideoCallSessionEntity session) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 110, + height: 110, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.verifiedGreen, width: 3), + image: const DecorationImage( + image: NetworkImage( + 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=200&auto=format&fit=crop&q=80', + ), + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 16), + Text( + session.patientName, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.shield_outlined, size: 12, color: AppColors.verifiedGreen), + SizedBox(width: 6), + Text( + 'Terenkripsi Klinis End-to-End', + style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], + ); + } + + Widget _buildRoundCallButton({ + required IconData icon, + required bool isActive, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isActive ? Colors.white.withValues(alpha: 0.2) : Colors.white10, + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: isActive ? Colors.white : Colors.white60, + size: 22, + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/main.dart b/apps/eva_counselor_mobile/lib/main.dart new file mode 100644 index 0000000..c29807c --- /dev/null +++ b/apps/eva_counselor_mobile/lib/main.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'core/routes/app_router.dart'; +import 'core/theme/app_theme.dart'; +import 'core/widgets/web_phone_frame_wrapper.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + GoogleFonts.pendingFonts([ + GoogleFonts.inter(), + ]); + runApp(const ProviderScope(child: EvaCounselorApp())); +} + +class EvaCounselorApp extends StatelessWidget { + const EvaCounselorApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'EVA Counselor — Portal Psikolog', + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme, + initialRoute: '/counselor-login', + onGenerateRoute: AppRouter.onGenerateRoute, + builder: (context, child) { + return WebPhoneFrameWrapper( + child: child ?? const SizedBox.shrink(), + ); + }, + ); + } +} diff --git a/apps/eva_counselor_mobile/pubspec.lock b/apps/eva_counselor_mobile/pubspec.lock new file mode 100644 index 0000000..3ea2919 --- /dev/null +++ b/apps/eva_counselor_mobile/pubspec.lock @@ -0,0 +1,594 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4 + url: "https://pub.dev" + source: hosted + version: "3.0.8" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: "2e91e6087291affd10b31b6c872f3e3ac7dc5d93b51b5ba485d930c8f08bde6c" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "5c80c2ad6e2c0397de5db38c6d653e69616a3397e13aa7633bf39eb668f2041b" + url: "https://pub.dev" + source: hosted + version: "9.6.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "33b38b07d9ac7d16aff0426c61418752fd3e7a8c7dc8be786c875d6ce673b002" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e396ac6f8751ab7d2d6e27f22f32c8350b07006028d534f5221503382f1fa69" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: fa49e1674cb84acb900141714e6c6e1f9ae099827c24d141430ff6819db4e1bc + url: "https://pub.dev" + source: hosted + version: "2.4.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" + web: + dependency: transitive + description: + name: web + sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.47.0" diff --git a/apps/eva_counselor_mobile/pubspec.yaml b/apps/eva_counselor_mobile/pubspec.yaml new file mode 100644 index 0000000..e874e9c --- /dev/null +++ b/apps/eva_counselor_mobile/pubspec.yaml @@ -0,0 +1,32 @@ +name: eva_counselor_mobile +description: "EVA Counselor & Psychologist Mobile Application" +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.11.5 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + google_fonts: ^6.2.1 + flutter_riverpod: ^2.6.1 + http: ^1.2.2 + shared_preferences: ^2.3.2 + url_launcher: ^6.3.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + + assets: + - assets/images/ + - assets/images/eva_logo.png + - assets/images/eva_logo_square.png diff --git a/apps/eva_counselor_mobile/test/features/finances/finance_repository_test.dart b/apps/eva_counselor_mobile/test/features/finances/finance_repository_test.dart new file mode 100644 index 0000000..e21ff5a --- /dev/null +++ b/apps/eva_counselor_mobile/test/features/finances/finance_repository_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_counselor_mobile/features/finances/data/datasources/mock_finance_datasource.dart'; +import 'package:eva_counselor_mobile/features/finances/data/repositories/finance_repository_impl.dart'; +import 'package:eva_counselor_mobile/features/finances/domain/entities/transaction_entity.dart'; + +void main() { + group('FinanceRepository Tests', () { + late FinanceRepositoryImpl repository; + late MockFinanceDataSource mockDataSource; + + setUp(() { + mockDataSource = MockFinanceDataSource(); + repository = FinanceRepositoryImpl(dataSource: mockDataSource); + }); + + test('getSummary returns accurate balance breakdown', () async { + final summary = await repository.getSummary(); + expect(summary.availableBalance, 2450000); + expect(summary.pendingClearingBalance, 400000); + expect(summary.totalEarningsThisMonth, 4850000); + }); + + test('getTransactions returns list with net earnings and status', () async { + final transactions = await repository.getTransactions(); + expect(transactions.isNotEmpty, true); + expect(transactions.first.status, TransactionStatus.completed); + expect(transactions.first.netEarnings, 180000); + }); + + test('requestPayout succeeds with valid banking parameters', () async { + final result = await repository.requestPayout( + amount: 2000000, + bankName: 'BCA', + accountNumber: '8192039182', + accountHolderName: 'Sarah Anindya', + ); + expect(result, true); + }); + }); +} diff --git a/apps/eva_counselor_mobile/test/features/patients/patient_repository_test.dart b/apps/eva_counselor_mobile/test/features/patients/patient_repository_test.dart new file mode 100644 index 0000000..f616a53 --- /dev/null +++ b/apps/eva_counselor_mobile/test/features/patients/patient_repository_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_counselor_mobile/features/patients/data/datasources/mock_patient_datasource.dart'; +import 'package:eva_counselor_mobile/features/patients/data/repositories/patient_repository_impl.dart'; + +void main() { + group('PatientRepository & Consent Gating Tests', () { + late PatientRepositoryImpl repository; + late MockPatientDataSource mockDataSource; + + setUp(() { + mockDataSource = MockPatientDataSource(); + repository = PatientRepositoryImpl(dataSource: mockDataSource); + }); + + test('getPatients returns list of mapped domain entities', () async { + final patients = await repository.getPatients(); + expect(patients.isNotEmpty, true); + expect(patients.any((p) => p.name == 'Rian Pratama'), true); + }); + + test('Patient with hasConsent == true provides access to emotional history', () async { + final patients = await repository.getPatients(); + final consentPatient = patients.firstWhere((p) => p.hasConsent); + + expect(consentPatient.hasConsent, true); + expect(consentPatient.dassHistory, isNotNull); + expect(consentPatient.dassHistory!.isNotEmpty, true); + }); + + test('Patient with hasConsent == false hides sensitive emotional records', () async { + final patients = await repository.getPatients(); + final lockedPatient = patients.firstWhere((p) => !p.hasConsent); + + expect(lockedPatient.hasConsent, false); + expect(lockedPatient.dassHistory, isNull); + }); + + test('requestPatientConsent triggers consent request successfully', () async { + final success = await repository.requestPatientConsent('patient-003'); + expect(success, true); + }); + + test('Patient with upcoming live session has isLiveNow == true and VC room ready', () async { + final patients = await repository.getPatients(); + final livePatient = patients.firstWhere((p) => p.id == 'patient-001'); + + expect(livePatient.upcomingSchedule, isNotNull); + expect(livePatient.upcomingSchedule!.isLiveNow, true); + expect(livePatient.upcomingSchedule!.timeRange, '10:00 - 10:50 WIB'); + }); + + test('Patient with future session has isLiveNow == false and VC room locked', () async { + final patients = await repository.getPatients(); + final futurePatient = patients.firstWhere((p) => p.id == 'patient-002'); + + expect(futurePatient.upcomingSchedule, isNotNull); + expect(futurePatient.upcomingSchedule!.isLiveNow, false); + expect(futurePatient.upcomingSchedule!.roomOpensAt, '13:45 WIB'); + }); + + test('Patient without active upcoming booking returns null upcomingSchedule', () async { + final patients = await repository.getPatients(); + final noSchedulePatient = patients.firstWhere((p) => p.id == 'patient-003'); + + expect(noSchedulePatient.upcomingSchedule, isNull); + }); + + test('saveClinicalNote creates and stores SOAP note', () async { + final note = await repository.saveClinicalNote( + patientId: 'patient-001', + sessionId: 'session-test-01', + subjective: 'Pasien merasa gelisah mengenai sidang skripsi.', + objective: 'Kontak mata terbatas, nafas cepat.', + assessment: 'Kecemasan performansi moderat.', + plan: 'Teknik pernapasan 4-7-8 dan cognitive restructuring.', + recommendedTasks: ['Pacer napas harian'], + ); + + expect(note.id.isNotEmpty, true); + expect(note.subjective, contains('gelisah')); + expect(note.assessment, contains('Kecemasan performansi')); + }); + }); +} diff --git a/apps/eva_counselor_mobile/test/widget_test.dart b/apps/eva_counselor_mobile/test/widget_test.dart new file mode 100644 index 0000000..e4cc1c0 --- /dev/null +++ b/apps/eva_counselor_mobile/test/widget_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:eva_counselor_mobile/main.dart'; + +void main() { + testWidgets('EvaCounselorApp smoke test - renders login portal', (WidgetTester tester) async { + await tester.pumpWidget(const ProviderScope(child: EvaCounselorApp())); + await tester.pump(); + + expect(find.text('Portal Psikolog EVA'), findsOneWidget); + expect(find.text('Masuk Sebagai Psikolog'), findsOneWidget); + }); +} diff --git a/apps/eva_counselor_mobile/web/favicon.png b/apps/eva_counselor_mobile/web/favicon.png new file mode 100644 index 0000000..3fc4436 Binary files /dev/null and b/apps/eva_counselor_mobile/web/favicon.png differ diff --git a/apps/eva_counselor_mobile/web/icons/Icon-192.png b/apps/eva_counselor_mobile/web/icons/Icon-192.png new file mode 100644 index 0000000..a94bb5f Binary files /dev/null and b/apps/eva_counselor_mobile/web/icons/Icon-192.png differ diff --git a/apps/eva_counselor_mobile/web/icons/Icon-512.png b/apps/eva_counselor_mobile/web/icons/Icon-512.png new file mode 100644 index 0000000..d99d6c8 Binary files /dev/null and b/apps/eva_counselor_mobile/web/icons/Icon-512.png differ diff --git a/apps/eva_counselor_mobile/web/icons/Icon-maskable-192.png b/apps/eva_counselor_mobile/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..a94bb5f Binary files /dev/null and b/apps/eva_counselor_mobile/web/icons/Icon-maskable-192.png differ diff --git a/apps/eva_counselor_mobile/web/icons/Icon-maskable-512.png b/apps/eva_counselor_mobile/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d99d6c8 Binary files /dev/null and b/apps/eva_counselor_mobile/web/icons/Icon-maskable-512.png differ diff --git a/apps/eva_counselor_mobile/web/index.html b/apps/eva_counselor_mobile/web/index.html new file mode 100644 index 0000000..2160541 --- /dev/null +++ b/apps/eva_counselor_mobile/web/index.html @@ -0,0 +1,70 @@ + + + + + + + + + EVA Counselor — Portal Psikolog Klinis + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/eva_counselor_mobile/web/manifest.json b/apps/eva_counselor_mobile/web/manifest.json new file mode 100644 index 0000000..fec9bf8 --- /dev/null +++ b/apps/eva_counselor_mobile/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "EVA Counselor — Portal Psikolog", + "short_name": "EVA Counselor", + "start_url": ".", + "display": "standalone", + "background_color": "#F6F8FF", + "theme_color": "#5358CB", + "description": "Portal telekonsultasi psikolog klinis, manajemen pasien berizin, dan sesi video call.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/apps/eva_mobile/README.md b/apps/eva_mobile/README.md index 9ddb365..03f7c3f 100644 --- a/apps/eva_mobile/README.md +++ b/apps/eva_mobile/README.md @@ -1,4 +1,4 @@ -# luna_mobile +# eva_mobile A new Flutter project. diff --git a/apps/eva_mobile/lib/config/app_config.dart b/apps/eva_mobile/lib/config/app_config.dart new file mode 100644 index 0000000..eaf9a44 --- /dev/null +++ b/apps/eva_mobile/lib/config/app_config.dart @@ -0,0 +1,234 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppConfig { + static bool useMockData = false; + static String? authToken; + static String? _cachedRefreshToken; + static String? _customHost; + + static String? _cachedUserName; + static String? _cachedUserEmail; + + /// Compile-time environment flag: --dart-define=USE_LOCAL_API=true or --dart-define=LOCAL=true + static const bool useLocalApi = bool.fromEnvironment( + 'USE_LOCAL_API', + defaultValue: bool.fromEnvironment('LOCAL', defaultValue: false), + ); + + /// Compile-time emulator flag: --dart-define=IS_EMULATOR=true or --dart-define=USE_EMULATOR=true + /// When true, Android Emulator uses 10.0.2.2:8888 instead of 127.0.0.1:8888. + static const bool isEmulator = bool.fromEnvironment( + 'IS_EMULATOR', + defaultValue: bool.fromEnvironment( + 'USE_EMULATOR', + defaultValue: bool.fromEnvironment('EMULATOR', defaultValue: false), + ), + ); + + /// Optional compile-time custom host: --dart-define=LOCAL_HOST=192.168.1.100:8888 + static const String customLocalHost = String.fromEnvironment( + 'LOCAL_HOST', + defaultValue: String.fromEnvironment('HOST', defaultValue: ''), + ); + + /// Production server domain + static const String defaultServerHost = 'eva.nexacode.dev'; + + /// Local development host (127.0.0.1:8888 with adb reverse for USB device, web, desktop) + static const String localServerHost = '127.0.0.1:8888'; + + /// Android emulator host (10.0.2.2:8888) + static const String emulatorServerHost = '10.0.2.2:8888'; + + /// 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 (customLocalHost.isNotEmpty) { + return customLocalHost; + } + if (useLocalApi) { + if (isEmulator) { + return emulatorServerHost; + } + return localServerHost; + } + return defaultServerHost; + } + + static void setCustomHost(String host) { + _customHost = host; + } + + /// Whether the current connection requires secure protocol (HTTPS / WSS). + /// Production domain uses HTTPS/WSS, while local dev loopbacks use HTTP/WS. + static bool get isSecure { + if (useLocalApi) return false; + final currentHost = host; + if (currentHost.startsWith('127.') || + currentHost.startsWith('10.') || + currentHost.startsWith('192.168.') || + currentHost.startsWith('localhost')) { + return false; + } + return true; + } + + static String get httpScheme => isSecure ? 'https' : 'http'; + static String get wsScheme => isSecure ? 'wss' : 'ws'; + + static String get baseUrl { + if (host.startsWith('http://') || host.startsWith('https://')) { + return '$host/api/v1'; + } + return '$httpScheme://$host/api/v1'; + } + + static String get wsUrl { + if (host.startsWith('ws://') || host.startsWith('wss://')) { + return '$host/api/v1'; + } + return '$wsScheme://$host/api/v1'; + } + + /// Get current JWT access token + static Future getToken() async { + if (authToken != null) return authToken; + final prefs = await SharedPreferences.getInstance(); + authToken = prefs.getString('auth_token'); + return authToken; + } + + /// Save JWT access token + static Future setToken(String token) async { + authToken = token; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('auth_token', token); + } + + /// Get current JWT refresh token + static Future getRefreshToken() async { + if (_cachedRefreshToken != null) return _cachedRefreshToken; + final prefs = await SharedPreferences.getInstance(); + _cachedRefreshToken = prefs.getString('refresh_token'); + return _cachedRefreshToken; + } + + /// Save JWT refresh token + static Future setRefreshToken(String refreshToken) async { + _cachedRefreshToken = refreshToken; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('refresh_token', refreshToken); + } + + /// Save both Access and Refresh tokens + static Future setTokens({ + required String accessToken, + required String refreshToken, + }) async { + authToken = accessToken; + _cachedRefreshToken = refreshToken; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('auth_token', accessToken); + await prefs.setString('refresh_token', refreshToken); + } + + /// Refresh access token using stored refresh token + static Future refreshAccessToken() async { + try { + final rfToken = await getRefreshToken(); + if (rfToken == null || rfToken.isEmpty) return false; + + final res = await http.post( + Uri.parse('$baseUrl/auth/refresh'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: jsonEncode({ + 'refresh_token': rfToken, + }), + ).timeout(const Duration(seconds: 5)); + + if (res.statusCode == 200) { + final data = jsonDecode(res.body) as Map; + final newAccess = data['access_token']?.toString(); + final newRefresh = data['refresh_token']?.toString() ?? rfToken; + + if (newAccess != null && newAccess.isNotEmpty) { + await setTokens(accessToken: newAccess, refreshToken: newRefresh); + if (data['user'] is Map) { + final userMap = data['user'] as Map; + await setUserInfo( + name: userMap['name']?.toString() ?? _cachedUserName ?? 'Sahabat EVA', + email: userMap['email']?.toString() ?? _cachedUserEmail ?? '', + ); + } + return true; + } + } + } catch (_) {} + return false; + } + + /// Get cached user name + static Future getUserName() async { + if (_cachedUserName != null) return _cachedUserName; + final prefs = await SharedPreferences.getInstance(); + _cachedUserName = prefs.getString('user_name'); + return _cachedUserName; + } + + /// Get cached user email + static Future getUserEmail() async { + if (_cachedUserEmail != null) return _cachedUserEmail; + final prefs = await SharedPreferences.getInstance(); + _cachedUserEmail = prefs.getString('user_email'); + return _cachedUserEmail; + } + + /// Save user profile info + static Future setUserInfo({required String name, required String email}) async { + _cachedUserName = name; + _cachedUserEmail = email; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user_name', name); + await prefs.setString('user_email', email); + } + + /// Clear token and profile info (Logout) + static Future clearToken() async { + authToken = null; + _cachedRefreshToken = null; + _cachedUserName = null; + _cachedUserEmail = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('auth_token'); + await prefs.remove('refresh_token'); + await prefs.remove('user_name'); + await prefs.remove('user_email'); + } + + /// Synchronous default headers + static Map get defaultHeaders => { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + if (authToken != null && authToken!.isNotEmpty) + 'Authorization': 'Bearer $authToken', + }; + + /// Default headers with Authorization if token is available + static Future> getAuthHeaders() async { + final token = await getToken(); + return { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + }; + } +} + diff --git a/apps/eva_mobile/lib/core/config/app_config.dart b/apps/eva_mobile/lib/core/config/app_config.dart new file mode 100644 index 0000000..dff04c5 --- /dev/null +++ b/apps/eva_mobile/lib/core/config/app_config.dart @@ -0,0 +1,2 @@ +export '../../config/app_config.dart'; + diff --git a/apps/eva_mobile/lib/core/network/api_client.dart b/apps/eva_mobile/lib/core/network/api_client.dart new file mode 100644 index 0000000..eab0806 --- /dev/null +++ b/apps/eva_mobile/lib/core/network/api_client.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../config/app_config.dart'; + +/// Custom Exception for API Errors +class ApiException implements Exception { + final String message; + final int? statusCode; + + ApiException(this.message, {this.statusCode}); + + @override + String toString() => 'ApiException: $message (StatusCode: $statusCode)'; +} + +/// Generic HTTP Client abstraction for Remote DataSources +class ApiClient { + final http.Client _client; + + ApiClient({http.Client? client}) : _client = client ?? http.Client(); + + Future get(String path, {Map? 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); + return _processResponse(response); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException('Failed to connect to backend: $e'); + } + } + + Future post(String path, {Map? body}) async { + try { + final cleanPath = path.startsWith('/') ? path : '/$path'; + final uri = Uri.parse('${AppConfig.baseUrl}$cleanPath'); + final headers = await AppConfig.getAuthHeaders(); + final response = await _client.post( + uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + ); + return _processResponse(response); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException('Failed to send data to backend: $e'); + } + } + + Future put(String path, {Map? body}) async { + try { + final cleanPath = path.startsWith('/') ? path : '/$path'; + final uri = Uri.parse('${AppConfig.baseUrl}$cleanPath'); + final headers = await AppConfig.getAuthHeaders(); + final response = await _client.put( + uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + ); + return _processResponse(response); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException('Failed to update data on backend: $e'); + } + } + + Future delete(String path) async { + try { + 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); + return _processResponse(response); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException('Failed to delete resource on backend: $e'); + } + } + + dynamic _processResponse(http.Response response) { + if (response.statusCode >= 200 && response.statusCode < 300) { + if (response.body.isEmpty) return null; + return jsonDecode(response.body); + } else { + final body = response.body.isNotEmpty ? jsonDecode(response.body) : {}; + final message = body['detail'] ?? body['message'] ?? 'HTTP Error ${response.statusCode}'; + throw ApiException(message.toString(), statusCode: response.statusCode); + } + } +} diff --git a/apps/eva_mobile/lib/core/routes/app_route_transitions.dart b/apps/eva_mobile/lib/core/routes/app_route_transitions.dart new file mode 100644 index 0000000..33fdaa8 --- /dev/null +++ b/apps/eva_mobile/lib/core/routes/app_route_transitions.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +/// Provides custom, hardware-accelerated PageRouteBuilders for Eva Mobile. +class AppRouteTransitions { + /// 1. Modal / Immersive Route: + /// Enters by sliding up from bottom with subtle scale-in & fade. + /// Exits by sliding back down quickly. + static Route modalSlideUpRoute({ + required Widget page, + RouteSettings? settings, + Duration duration = const Duration(milliseconds: 320), + Duration reverseDuration = const Duration(milliseconds: 240), + }) { + return PageRouteBuilder( + settings: settings, + transitionDuration: duration, + reverseTransitionDuration: reverseDuration, + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + + return FadeTransition( + opacity: curved, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.12), + end: Offset.zero, + ).animate(curved), + child: ScaleTransition( + scale: Tween(begin: 0.98, end: 1.0).animate(curved), + child: child, + ), + ), + ); + }, + ); + } + + /// 2. Hierarchical Forward/Back Route: + /// Enters by sliding in from right with fade. + /// Applies a subtle parallax push-back to the outgoing background page. + /// Exits by sliding back out to the right. + static Route horizontalDetailRoute({ + required Widget page, + RouteSettings? settings, + Duration duration = const Duration(milliseconds: 300), + Duration reverseDuration = const Duration(milliseconds: 240), + }) { + return PageRouteBuilder( + settings: settings, + transitionDuration: duration, + reverseTransitionDuration: reverseDuration, + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final forwardCurved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + + final secondaryCurved = CurvedAnimation( + parent: secondaryAnimation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + + // Slide in from right for incoming page + final incomingSlide = Tween( + begin: const Offset(0.22, 0.0), + end: Offset.zero, + ).animate(forwardCurved); + + // Subtle parallax push-back for outgoing page + final outgoingSlide = Tween( + begin: Offset.zero, + end: const Offset(-0.05, 0.0), + ).animate(secondaryCurved); + + final incomingFade = Tween(begin: 0.0, end: 1.0).animate(forwardCurved); + + return SlideTransition( + position: outgoingSlide, + child: SlideTransition( + position: incomingSlide, + child: FadeTransition( + opacity: incomingFade, + child: child, + ), + ), + ); + }, + ); + } + + /// 3. Root Context & Auth Shift Route: + /// Soft cross-fade with subtle depth expansion. + static Route fadeThroughRoute({ + required Widget page, + RouteSettings? settings, + Duration duration = const Duration(milliseconds: 280), + Duration reverseDuration = const Duration(milliseconds: 220), + }) { + return PageRouteBuilder( + settings: settings, + transitionDuration: duration, + reverseTransitionDuration: reverseDuration, + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + + return FadeTransition( + opacity: curved, + child: ScaleTransition( + scale: Tween(begin: 0.97, end: 1.0).animate(curved), + child: child, + ), + ); + }, + ); + } +} diff --git a/apps/eva_mobile/lib/core/routes/app_router.dart b/apps/eva_mobile/lib/core/routes/app_router.dart new file mode 100644 index 0000000..d3c34e6 --- /dev/null +++ b/apps/eva_mobile/lib/core/routes/app_router.dart @@ -0,0 +1,129 @@ +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 'app_route_transitions.dart'; + +/// Centralized route generator applying custom transitions to all Eva Mobile pages. +class AppRouter { + static Route? onGenerateRoute(RouteSettings settings) { + final name = settings.name; + final args = settings.arguments as Map?; + + switch (name) { + // 1. Root Context & Auth Shift (fadeThroughRoute) + case '/': + return AppRouteTransitions.fadeThroughRoute( + page: const SplashOnboardingScreen(), + settings: settings, + ); + case '/login': + return AppRouteTransitions.fadeThroughRoute( + page: const LoginScreen(), + settings: settings, + ); + case '/register': + return AppRouteTransitions.fadeThroughRoute( + page: const RegisterScreen(), + settings: settings, + ); + case '/home': + final initialIndex = (args?['initialIndex'] as int?) ?? 0; + return AppRouteTransitions.fadeThroughRoute( + page: MainShellScreen(initialIndex: initialIndex), + settings: settings, + ); + case '/diary': + return AppRouteTransitions.fadeThroughRoute( + page: const AiDiaryScreen(), + settings: settings, + ); + case '/monitoring': + return AppRouteTransitions.fadeThroughRoute( + page: const MonitoringScreen(), + settings: settings, + ); + case '/profile': + return AppRouteTransitions.fadeThroughRoute( + page: const ProfileScreen(), + settings: settings, + ); + + // 2. Modal / Immersive Experience (modalSlideUpRoute) + case '/chat': + return AppRouteTransitions.modalSlideUpRoute( + page: const AiConversationScreen(), + settings: settings, + ); + case '/voice_call': + case '/call': + return AppRouteTransitions.modalSlideUpRoute( + page: const VoiceCallScreen(), + settings: settings, + ); + case '/support': + case '/support_emergency': + return AppRouteTransitions.modalSlideUpRoute( + page: const SupportEmergencyScreen(), + settings: settings, + ); + case '/dass_assessment': + case '/dass': + final initialPeriod = (args?['period'] as String?) ?? 'today'; + return AppRouteTransitions.modalSlideUpRoute( + page: DASSAssessmentScreen(initialPeriod: initialPeriod), + settings: settings, + ); + + // 3. Hierarchical Forward/Back (horizontalDetailRoute) + case '/diary_detail': + return AppRouteTransitions.horizontalDetailRoute( + page: AiDiaryDetailScreen(journalData: args), + settings: settings, + ); + case '/voice_session_detail': + return AppRouteTransitions.horizontalDetailRoute( + page: VoiceSessionDetailScreen(sessionData: args), + settings: settings, + ); + case '/emergency_contacts': + return AppRouteTransitions.horizontalDetailRoute( + page: const EmergencyContactsScreen(), + settings: settings, + ); + case '/settings': + return AppRouteTransitions.horizontalDetailRoute( + page: const SettingsScreen(), + settings: settings, + ); + case '/recommendation': + return AppRouteTransitions.horizontalDetailRoute( + page: const RecommendationScreen(), + settings: settings, + ); + case '/terms_disclaimer': + return AppRouteTransitions.horizontalDetailRoute( + page: const TermsDisclaimerScreen(), + settings: settings, + ); + + default: + return null; + } + } +} diff --git a/apps/eva_mobile/lib/core/storage/token_storage.dart b/apps/eva_mobile/lib/core/storage/token_storage.dart new file mode 100644 index 0000000..e8628ad --- /dev/null +++ b/apps/eva_mobile/lib/core/storage/token_storage.dart @@ -0,0 +1,23 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class TokenStorage { + static const String _keyToken = 'jwt_token'; + + /// Save JWT token to SharedPreferences + static Future saveToken(String token) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyToken, token); + } + + /// Retrieve stored JWT token from SharedPreferences + static Future getToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keyToken); + } + + /// Clear stored JWT token from SharedPreferences + static Future clearToken() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyToken); + } +} diff --git a/apps/eva_mobile/lib/core/utils/responsive_layout_helper.dart b/apps/eva_mobile/lib/core/utils/responsive_layout_helper.dart new file mode 100644 index 0000000..4a54fd6 --- /dev/null +++ b/apps/eva_mobile/lib/core/utils/responsive_layout_helper.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +/// Extension providing responsive layout and typography utilities across Eva Mobile. +extension ResponsiveLayoutExtension on BuildContext { + /// Responsive font scale factor clamped between 0.85 (compact devices ~320-360px) + /// and 1.10 (large phones / tablets). Baseline standard viewport width is 390px. + double get fontScale { + final width = MediaQuery.sizeOf(this).width; + return (width / 390.0).clamp(0.85, 1.10); + } + + /// Calculates a scaled font size based on the current screen width. + double responsiveFont(double baseSize) { + return (baseSize * fontScale).roundToDouble(); + } +} diff --git a/apps/eva_mobile/lib/data/repositories/voice_call_repository.dart b/apps/eva_mobile/lib/data/repositories/voice_call_repository.dart new file mode 100644 index 0000000..0bf27d5 --- /dev/null +++ b/apps/eva_mobile/lib/data/repositories/voice_call_repository.dart @@ -0,0 +1,227 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import 'package:http/http.dart' as http; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../../core/config/app_config.dart'; + +class VoiceCallRepository { + WebSocketChannel? _channel; + StreamSubscription? _subscription; + + final StreamController> _eventController = + StreamController>.broadcast(); + final StreamController _audioStreamController = + StreamController.broadcast(); + + Stream> get eventStream => _eventController.stream; + Stream get audioStream => _audioStreamController.stream; + + bool _isConnected = false; + bool get isConnected => _isConnected; + + Future> fetchTodayVoiceSessions({int page = 1, int limit = 10}) async { + try { + final String rawBase = AppConfig.baseUrl.endsWith('/') + ? AppConfig.baseUrl.substring(0, AppConfig.baseUrl.length - 1) + : AppConfig.baseUrl; + final String endpointPath = rawBase.endsWith('/api/v1') + ? '/conversations/today' + : '/api/v1/conversations/today'; + final uri = Uri.parse('$rawBase$endpointPath?page=$page&limit=$limit'); + + debugPrint('📡 [CALL REPO] GET Today Conversations Paginated: $uri'); + + final response = await http.get(uri); + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final List rawItems = decoded['items'] as List? ?? []; + final items = rawItems.map((item) => Map.from(item as Map)).toList(); + return { + 'items': items, + 'total': decoded['total'] ?? items.length, + 'page': decoded['page'] ?? page, + 'has_more': decoded['has_more'] ?? false, + }; + } else if (decoded is List) { + final items = decoded.map((item) => Map.from(item as Map)).toList(); + return { + 'items': items, + 'total': items.length, + 'page': page, + 'has_more': false, + }; + } + } + return {'items': >[], 'total': 0, 'page': page, 'has_more': false}; + } catch (e) { + debugPrint('❌ [CALL REPO EXCEPTION] fetchTodayVoiceSessions failed: $e'); + return {'items': >[], 'total': 0, 'page': page, 'has_more': false}; + } + } + + Future> fetchTodayMessages({int page = 1, int limit = 10}) async { + try { + final String rawBase = AppConfig.baseUrl.endsWith('/') + ? AppConfig.baseUrl.substring(0, AppConfig.baseUrl.length - 1) + : AppConfig.baseUrl; + final String endpointPath = rawBase.endsWith('/api/v1') + ? '/conversations/today/messages' + : '/api/v1/conversations/today/messages'; + final uri = Uri.parse('$rawBase$endpointPath?page=$page&limit=$limit'); + + debugPrint('📡 [CALL REPO] GET Today Messages Paginated: $uri'); + + final response = await http.get(uri); + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final List rawItems = decoded['items'] as List? ?? []; + final items = rawItems.map((item) => Map.from(item as Map)).toList(); + return { + 'items': items, + 'total_items': decoded['total_items'] ?? items.length, + 'page': decoded['page'] ?? page, + 'limit': decoded['limit'] ?? limit, + 'total_pages': decoded['total_pages'] ?? 1, + }; + } + } + return {'items': >[], 'total_items': 0, 'page': page, 'limit': limit, 'total_pages': 1}; + } catch (e) { + debugPrint('❌ [CALL REPO EXCEPTION] fetchTodayMessages failed: $e'); + return {'items': >[], 'total_items': 0, 'page': page, 'limit': limit, 'total_pages': 1}; + } + } + + Future?> fetchConversationById(String conversationId) async { + try { + final String rawBase = AppConfig.baseUrl.endsWith('/') + ? AppConfig.baseUrl.substring(0, AppConfig.baseUrl.length - 1) + : AppConfig.baseUrl; + final String endpointPath = rawBase.endsWith('/api/v1') + ? '/conversations/$conversationId' + : '/api/v1/conversations/$conversationId'; + final uri = Uri.parse('$rawBase$endpointPath'); + + final response = await http.get(uri); + if (response.statusCode == 200) { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + return Map.from(decoded); + } + } + return null; + } catch (e) { + debugPrint('❌ [CALL REPO EXCEPTION] fetchConversationById failed: $e'); + return null; + } + } + + void connect(String sessionId) { + if (_isConnected) return; + + final wsBase = AppConfig.wsUrl.endsWith('/') + ? AppConfig.wsUrl.substring(0, AppConfig.wsUrl.length - 1) + : AppConfig.wsUrl; + final uri = Uri.parse('$wsBase/api/v1/call/ws/$sessionId'); + debugPrint('🔌 [CALL REPO] Connecting WebSocket: $uri'); + + try { + _channel = WebSocketChannel.connect(uri); + _isConnected = true; + + _subscription = _channel!.stream.listen( + (data) { + if (data is Uint8List) { + debugPrint('🎧 [CALL REPO <- WS Audio] Received ${data.length} audio bytes'); + _audioStreamController.add(data); + } else if (data is List) { + final bytes = Uint8List.fromList(data); + debugPrint('🎧 [CALL REPO <- WS Audio] Received ${bytes.length} audio bytes'); + _audioStreamController.add(bytes); + } else if (data is String) { + try { + final jsonMap = jsonDecode(data) as Map; + debugPrint('📩 [CALL REPO <- WS Event] ${jsonMap['type']}: $jsonMap'); + _eventController.add(jsonMap); + } catch (_) { + debugPrint('📩 [CALL REPO <- WS Raw Text] $data'); + } + } + }, + onError: (error) { + debugPrint('❌ [CALL REPO ERROR] WebSocket error: $error'); + _eventController.add({ + 'type': 'error', + 'message': error.toString(), + }); + _isConnected = false; + }, + onDone: () { + debugPrint('🔌 [CALL REPO DISCONNECTED] WebSocket closed.'); + _isConnected = false; + _eventController.add({'type': 'disconnected'}); + }, + ); + + sendJson({'type': 'start_call'}); + } catch (e) { + debugPrint('❌ [CALL REPO EXCEPTION] Failed to connect: $e'); + _isConnected = false; + _eventController.add({ + 'type': 'error', + 'message': 'Failed to connect: $e', + }); + } + } + + void sendAudioChunk(Uint8List bytes) { + if (_channel != null && _isConnected) { + debugPrint('đŸŽ™ī¸ [CALL REPO -> WS Audio] Sending ${bytes.length} mic bytes'); + _channel!.sink.add(bytes); + } + } + + void sendUserTranscript(String text) { + debugPrint('đŸ’Ŧ [CALL REPO -> WS Transcript] Sending: "$text"'); + sendJson({ + 'type': 'user_transcript', + 'text': text, + }); + } + + void sendInterrupt() { + debugPrint('⚡ [CALL REPO -> WS Interrupt]'); + sendJson({'type': 'user_interrupted'}); + } + + void sendJson(Map payload) { + if (_channel != null && _isConnected) { + _channel!.sink.add(jsonEncode(payload)); + } + } + + void disconnect({int? durationSeconds}) { + if (_isConnected) { + final payload = {'type': 'end_call'}; + if (durationSeconds != null && durationSeconds > 0) { + payload['duration_seconds'] = durationSeconds; + } + sendJson(payload); + } + _subscription?.cancel(); + _channel?.sink.close(); + _isConnected = false; + } + + void dispose() { + disconnect(); + _eventController.close(); + _audioStreamController.close(); + } +} 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 new file mode 100644 index 0000000..00494cf --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/datasources/auth_datasource.dart @@ -0,0 +1,12 @@ +import '../models/user_model.dart'; +import '../models/emergency_contact_model.dart'; + +abstract class AuthDataSource { + Future getCurrentUser(); + Future login(String email, String password); + Future register(String name, String email, String password); + Future logout(); + Future> getEmergencyContacts(); + Future addEmergencyContact(EmergencyContactModel contact); + Future deleteEmergencyContact(String id); +} 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 new file mode 100644 index 0000000..2d431d7 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart @@ -0,0 +1,96 @@ +import 'auth_datasource.dart'; +import '../models/user_model.dart'; +import '../models/emergency_contact_model.dart'; +import '../../../../core/storage/token_storage.dart'; + +class MockAuthDataSource implements AuthDataSource { + UserModel? _currentUser = const UserModel( + id: 'usr_001', + name: 'Samsul', + email: 'samsul@gmail.com', + phone: '+62 812-3456-7890', + bio: 'Pengguna aktif EVA AI.', + ); + + final List _contacts = [ + const EmergencyContactModel( + id: 'c1', + name: 'Ibu (Siti Rahma)', + relationship: 'Keluarga Utama', + phone: '+62 812-9988-7766', + isPrimary: true, + ), + const EmergencyContactModel( + id: 'c2', + name: 'Layanan Darurat Konseling 24/7', + relationship: 'Hotline Kesehatan Mental', + phone: '119 (Ext 8)', + isPrimary: false, + ), + const EmergencyContactModel( + id: 'c3', + name: 'Dr. Anita, Sp.KJ', + relationship: 'Psikiater Pendamping', + phone: '+62 811-2233-4455', + isPrimary: false, + ), + ]; + + @override + Future getCurrentUser() async { + await Future.delayed(const Duration(milliseconds: 300)); + return _currentUser; + } + + @override + Future login(String email, String password) async { + await Future.delayed(const Duration(milliseconds: 500)); + _currentUser = UserModel( + id: 'usr_001', + name: email.split('@').first, + email: email, + phone: '+62 812-3456-7890', + ); + await TokenStorage.saveToken('mock_jwt_token'); + return _currentUser!; + } + + @override + Future register(String name, String email, String password) async { + await Future.delayed(const Duration(milliseconds: 500)); + _currentUser = UserModel( + id: 'usr_${DateTime.now().millisecondsSinceEpoch}', + name: name, + email: email, + ); + await TokenStorage.saveToken('mock_jwt_token'); + return _currentUser!; + } + + @override + Future logout() async { + await Future.delayed(const Duration(milliseconds: 300)); + _currentUser = null; + await TokenStorage.clearToken(); + } + + @override + Future> getEmergencyContacts() async { + await Future.delayed(const Duration(milliseconds: 300)); + return List.unmodifiable(_contacts); + } + + @override + Future addEmergencyContact(EmergencyContactModel contact) async { + await Future.delayed(const Duration(milliseconds: 300)); + final newContact = contact.copyWith(id: 'c_${DateTime.now().millisecondsSinceEpoch}'); + _contacts.add(newContact); + return newContact; + } + + @override + Future deleteEmergencyContact(String id) async { + await Future.delayed(const Duration(milliseconds: 300)); + _contacts.removeWhere((c) => c.id == id); + } +} 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 new file mode 100644 index 0000000..e41031d --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart @@ -0,0 +1,75 @@ +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 '../../../../core/storage/token_storage.dart'; + +class RemoteAuthDataSource implements AuthDataSource { + final ApiClient _apiClient; + + RemoteAuthDataSource({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + @override + Future getCurrentUser() async { + final response = await _apiClient.get('/auth/me'); + if (response == null) return null; + return UserModel.fromJson(response); + } + + @override + Future login(String email, String password) async { + final response = await _apiClient.post('/auth/login', body: { + 'email': email, + 'password': password, + }); + if (response['access_token'] != null) { + final token = response['access_token'] as String; + AppConfig.authToken = token; + await TokenStorage.saveToken(token); + } + return UserModel.fromJson(response['user']); + } + + @override + Future register(String name, String email, String password) async { + final response = await _apiClient.post('/auth/register', body: { + 'name': name, + 'email': email, + 'password': password, + }); + if (response['access_token'] != null) { + final token = response['access_token'] as String; + AppConfig.authToken = token; + await TokenStorage.saveToken(token); + } + return UserModel.fromJson(response['user']); + } + + @override + Future logout() async { + try { + await _apiClient.post('/auth/logout'); + } catch (_) {} + AppConfig.authToken = null; + await TokenStorage.clearToken(); + } + + @override + Future> getEmergencyContacts() async { + final response = await _apiClient.get('/users/emergency-contacts'); + final list = response as List? ?? []; + return list.map((e) => EmergencyContactModel.fromJson(e)).toList(); + } + + @override + Future addEmergencyContact(EmergencyContactModel contact) async { + final response = await _apiClient.post('/users/emergency-contacts', body: contact.toJson()); + return EmergencyContactModel.fromJson(response); + } + + @override + Future deleteEmergencyContact(String id) async { + await _apiClient.delete('/users/emergency-contacts/$id'); + } +} diff --git a/apps/eva_mobile/lib/features/auth/data/models/emergency_contact_model.dart b/apps/eva_mobile/lib/features/auth/data/models/emergency_contact_model.dart new file mode 100644 index 0000000..72da487 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/models/emergency_contact_model.dart @@ -0,0 +1,47 @@ +import '../../domain/entities/emergency_contact_entity.dart'; + +class EmergencyContactModel extends EmergencyContactEntity { + const EmergencyContactModel({ + required super.id, + required super.name, + required super.relationship, + required super.phone, + super.isPrimary = false, + }); + + factory EmergencyContactModel.fromJson(Map json) { + return EmergencyContactModel( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + relationship: json['relationship']?.toString() ?? '', + phone: json['phone']?.toString() ?? '', + isPrimary: json['is_primary'] ?? json['isPrimary'] ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'relationship': relationship, + 'phone': phone, + 'is_primary': isPrimary, + }; + } + + EmergencyContactModel copyWith({ + String? id, + String? name, + String? relationship, + String? phone, + bool? isPrimary, + }) { + return EmergencyContactModel( + id: id ?? this.id, + name: name ?? this.name, + relationship: relationship ?? this.relationship, + phone: phone ?? this.phone, + isPrimary: isPrimary ?? this.isPrimary, + ); + } +} diff --git a/apps/eva_mobile/lib/features/auth/data/models/user_model.dart b/apps/eva_mobile/lib/features/auth/data/models/user_model.dart new file mode 100644 index 0000000..57e4f02 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/models/user_model.dart @@ -0,0 +1,52 @@ +import '../../domain/entities/user_entity.dart'; + +class UserModel extends UserEntity { + const UserModel({ + required super.id, + required super.name, + required super.email, + super.avatarUrl, + super.phone, + super.bio, + }); + + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + email: json['email']?.toString() ?? '', + avatarUrl: json['avatar_url'] ?? json['avatarUrl'], + phone: json['phone']?.toString(), + bio: json['bio']?.toString(), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'avatar_url': avatarUrl, + 'phone': phone, + 'bio': bio, + }; + } + + UserModel copyWith({ + String? id, + String? name, + String? email, + String? avatarUrl, + String? phone, + String? bio, + }) { + return UserModel( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + avatarUrl: avatarUrl ?? this.avatarUrl, + phone: phone ?? this.phone, + bio: bio ?? this.bio, + ); + } +} 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 new file mode 100644 index 0000000..f41abfa --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,41 @@ +import '../../domain/repositories/auth_repository.dart'; +import '../../domain/entities/user_entity.dart'; +import '../../domain/entities/emergency_contact_entity.dart'; +import '../datasources/auth_datasource.dart'; +import '../models/emergency_contact_model.dart'; + +class AuthRepositoryImpl implements AuthRepository { + final AuthDataSource _dataSource; + + AuthRepositoryImpl(this._dataSource); + + @override + Future getCurrentUser() => _dataSource.getCurrentUser(); + + @override + Future login(String email, String password) => _dataSource.login(email, password); + + @override + Future register(String name, String email, String password) => _dataSource.register(name, email, password); + + @override + Future logout() => _dataSource.logout(); + + @override + Future> getEmergencyContacts() => _dataSource.getEmergencyContacts(); + + @override + Future addEmergencyContact(EmergencyContactEntity contact) { + final model = EmergencyContactModel( + id: contact.id, + name: contact.name, + relationship: contact.relationship, + phone: contact.phone, + isPrimary: contact.isPrimary, + ); + return _dataSource.addEmergencyContact(model); + } + + @override + Future deleteEmergencyContact(String id) => _dataSource.deleteEmergencyContact(id); +} diff --git a/apps/eva_mobile/lib/features/auth/domain/entities/emergency_contact_entity.dart b/apps/eva_mobile/lib/features/auth/domain/entities/emergency_contact_entity.dart new file mode 100644 index 0000000..1ab1ed9 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/domain/entities/emergency_contact_entity.dart @@ -0,0 +1,15 @@ +class EmergencyContactEntity { + final String id; + final String name; + final String relationship; + final String phone; + final bool isPrimary; + + const EmergencyContactEntity({ + required this.id, + required this.name, + required this.relationship, + required this.phone, + this.isPrimary = false, + }); +} diff --git a/apps/eva_mobile/lib/features/auth/domain/entities/user_entity.dart b/apps/eva_mobile/lib/features/auth/domain/entities/user_entity.dart new file mode 100644 index 0000000..499de9c --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/domain/entities/user_entity.dart @@ -0,0 +1,17 @@ +class UserEntity { + final String id; + final String name; + final String email; + final String? avatarUrl; + final String? phone; + final String? bio; + + const UserEntity({ + required this.id, + required this.name, + required this.email, + this.avatarUrl, + this.phone, + this.bio, + }); +} 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 new file mode 100644 index 0000000..915a9a6 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,12 @@ +import '../entities/user_entity.dart'; +import '../entities/emergency_contact_entity.dart'; + +abstract class AuthRepository { + Future getCurrentUser(); + Future login(String email, String password); + Future register(String name, String email, String password); + Future logout(); + Future> getEmergencyContacts(); + Future addEmergencyContact(EmergencyContactEntity contact); + Future deleteEmergencyContact(String id); +} 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 new file mode 100644 index 0000000..c9777d3 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/providers/auth_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/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'; + +final authRepositoryProvider = Provider((ref) { + if (AppConfig.useMockData) { + return AuthRepositoryImpl(MockAuthDataSource()); + } else { + return AuthRepositoryImpl(RemoteAuthDataSource()); + } +}); + +final currentUserProvider = FutureProvider((ref) async { + final repo = ref.watch(authRepositoryProvider); + return repo.getCurrentUser(); +}); + +final emergencyContactsProvider = FutureProvider>((ref) async { + final repo = ref.watch(authRepositoryProvider); + return repo.getEmergencyContacts(); +}); 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 new file mode 100644 index 0000000..ab86969 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/datasources/chat_datasource.dart @@ -0,0 +1,8 @@ +import '../models/conversation_model.dart'; +import '../models/chat_message_model.dart'; + +abstract class ChatDataSource { + Future> getConversations(); + Future getConversationById(String id); + Future sendMessage(String conversationId, String text); +} 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 new file mode 100644 index 0000000..682f8a9 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/datasources/mock_chat_datasource.dart @@ -0,0 +1,114 @@ +import 'chat_datasource.dart'; +import '../models/conversation_model.dart'; +import '../models/chat_message_model.dart'; + +class MockChatDataSource implements ChatDataSource { + final List _conversations = [ + ConversationModel( + id: '1', + title: 'Refleksi Harian & Evaluasi Ujian', + date: '24 Oktober 2023', + 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).', + messages: const [ + ChatMessageModel( + id: 'm1', + conversationId: '1', + isUser: true, + time: '09:15 AM', + text: 'Saya sangat cemas dan takut tidak bisa menyelesaikan tugas kuliah ini dengan baik.', + emotionTag: 'fear (68%)', + emotionEmoji: '😨', + ), + ChatMessageModel( + id: 'm2', + conversationId: '1', + isUser: false, + time: '09:16 AM', + text: 'Aku mendengarmu. Sangat wajar merasa cemas saat tugas menumpuk. Mari kita uraikan bersama menjadi langkah kecil ya.', + ), + ChatMessageModel( + id: 'm3', + conversationId: '1', + isUser: true, + time: '16:30 PM', + text: 'Saya baru saja minum teh dan berjalan santai sebentar. Rasanya sedikit lebih lega.', + emotionTag: 'netral (60%)', + emotionEmoji: '😐', + ), + ChatMessageModel( + id: 'm4', + conversationId: '1', + isUser: false, + time: '16:31 PM', + text: 'Itu langkah yang luar biasa! Memberikan waktu istirahat pada pikiran sangat penting untuk pemulihan energimu.', + ), + ], + ), + ]; + + @override + Future> getConversations() async { + await Future.delayed(const Duration(milliseconds: 300)); + return List.unmodifiable(_conversations); + } + + @override + Future getConversationById(String id) async { + await Future.delayed(const Duration(milliseconds: 300)); + try { + return _conversations.firstWhere((c) => c.id == id); + } catch (_) { + return _conversations.isNotEmpty ? _conversations.first : null; + } + } + + @override + Future sendMessage(String conversationId, String text) async { + await Future.delayed(const Duration(milliseconds: 400)); + final now = DateTime.now(); + final timeStr = '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}'; + + final userMsg = ChatMessageModel( + id: 'm_${now.millisecondsSinceEpoch}', + conversationId: conversationId, + isUser: true, + time: timeStr, + text: text, + emotionTag: 'calm (75%)', + emotionEmoji: '😌', + ); + + // Append to local store if exists + final index = _conversations.indexWhere((c) => c.id == conversationId); + if (index != -1) { + final current = _conversations[index]; + final updatedMsgs = List.from(current.messages)..add(userMsg); + + // Simulate AI response + final aiMsg = ChatMessageModel( + id: 'm_ai_${now.millisecondsSinceEpoch}', + conversationId: conversationId, + isUser: false, + time: timeStr, + text: 'Terima kasih sudah berbagi denganku. Aku selalu ada di sini untuk mendengarkan dan mendukungmu.', + ); + updatedMsgs.add(aiMsg); + + _conversations[index] = ConversationModel( + id: current.id, + title: current.title, + date: current.date, + lastSessionTime: timeStr, + moodTag: current.moodTag, + moodEmoji: current.moodEmoji, + summary: current.summary, + messages: updatedMsgs, + ); + } + + return userMsg; + } +} 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 new file mode 100644 index 0000000..8c7fa6f --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart @@ -0,0 +1,40 @@ +import 'chat_datasource.dart'; +import '../models/conversation_model.dart'; +import '../models/chat_message_model.dart'; +import '../../../../core/network/api_client.dart'; + +class RemoteChatDataSource implements ChatDataSource { + final ApiClient _apiClient; + + RemoteChatDataSource({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + @override + Future> getConversations() async { + final response = await _apiClient.get('/conversations'); + final List list; + if (response is Map && response['items'] is List) { + list = response['items'] as List; + } else if (response is List) { + list = response; + } else { + list = []; + } + return list.map((e) => ConversationModel.fromJson(e as Map)).toList(); + } + + @override + Future getConversationById(String id) async { + final response = await _apiClient.get('/conversations/$id'); + if (response == null) return null; + return ConversationModel.fromJson(response); + } + + @override + Future sendMessage(String conversationId, String text) async { + final response = await _apiClient.post('/conversations/$conversationId/messages', body: { + 'content': text, + 'modality': 'text', + }); + return ChatMessageModel.fromJson(response); + } +} diff --git a/apps/eva_mobile/lib/features/chat/data/models/chat_message_model.dart b/apps/eva_mobile/lib/features/chat/data/models/chat_message_model.dart new file mode 100644 index 0000000..153cbe3 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/models/chat_message_model.dart @@ -0,0 +1,57 @@ +import '../../domain/entities/chat_message_entity.dart'; + +class ChatMessageModel extends ChatMessageEntity { + const ChatMessageModel({ + required super.id, + required super.conversationId, + required super.isUser, + required super.time, + required super.text, + super.emotionTag, + super.emotionEmoji, + }); + + factory ChatMessageModel.fromJson(Map json) { + return ChatMessageModel( + id: json['id']?.toString() ?? '', + conversationId: json['conversation_id']?.toString() ?? json['conversationId']?.toString() ?? '', + isUser: json['is_user'] ?? json['isUser'] ?? (json['sender_role'] == 'user'), + time: json['time']?.toString() ?? json['timestamp']?.toString() ?? '', + text: json['text']?.toString() ?? json['content']?.toString() ?? '', + emotionTag: json['emotion_tag'] ?? json['emotionTag'], + emotionEmoji: json['emotion_emoji'] ?? json['emotionEmoji'], + ); + } + + Map toJson() { + return { + 'id': id, + 'conversation_id': conversationId, + 'is_user': isUser, + 'time': time, + 'text': text, + 'emotion_tag': emotionTag, + 'emotion_emoji': emotionEmoji, + }; + } + + ChatMessageModel copyWith({ + String? id, + String? conversationId, + bool? isUser, + String? time, + String? text, + String? emotionTag, + String? emotionEmoji, + }) { + return ChatMessageModel( + id: id ?? this.id, + conversationId: conversationId ?? this.conversationId, + isUser: isUser ?? this.isUser, + time: time ?? this.time, + text: text ?? this.text, + emotionTag: emotionTag ?? this.emotionTag, + emotionEmoji: emotionEmoji ?? this.emotionEmoji, + ); + } +} diff --git a/apps/eva_mobile/lib/features/chat/data/models/conversation_model.dart b/apps/eva_mobile/lib/features/chat/data/models/conversation_model.dart new file mode 100644 index 0000000..58f066a --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/models/conversation_model.dart @@ -0,0 +1,42 @@ +import '../../domain/entities/conversation_entity.dart'; +import 'chat_message_model.dart'; + +class ConversationModel extends ConversationEntity { + const ConversationModel({ + required super.id, + required super.title, + required super.date, + required super.lastSessionTime, + required super.moodTag, + required super.moodEmoji, + required super.summary, + required super.messages, + }); + + factory ConversationModel.fromJson(Map json) { + final rawMessages = (json['messages'] ?? json['transcripts']) as List? ?? []; + return ConversationModel( + id: json['id']?.toString() ?? '', + title: json['title']?.toString() ?? '', + date: json['date']?.toString() ?? '', + lastSessionTime: json['last_session_time']?.toString() ?? json['lastSessionTime']?.toString() ?? '', + moodTag: json['mood_tag']?.toString() ?? json['moodTag']?.toString() ?? '', + moodEmoji: json['mood_emoji']?.toString() ?? json['moodEmoji']?.toString() ?? '', + summary: json['summary']?.toString() ?? '', + messages: rawMessages.map((m) => ChatMessageModel.fromJson(m)).toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'date': date, + 'last_session_time': lastSessionTime, + 'mood_tag': moodTag, + 'mood_emoji': moodEmoji, + 'summary': summary, + 'messages': messages.map((m) => (m as ChatMessageModel).toJson()).toList(), + }; + } +} 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 new file mode 100644 index 0000000..6257915 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/repositories/chat_repository_impl.dart @@ -0,0 +1,19 @@ +import '../../domain/repositories/chat_repository.dart'; +import '../../domain/entities/conversation_entity.dart'; +import '../../domain/entities/chat_message_entity.dart'; +import '../datasources/chat_datasource.dart'; + +class ChatRepositoryImpl implements ChatRepository { + final ChatDataSource _dataSource; + + ChatRepositoryImpl(this._dataSource); + + @override + Future> getConversations() => _dataSource.getConversations(); + + @override + Future getConversationById(String id) => _dataSource.getConversationById(id); + + @override + Future sendMessage(String conversationId, String text) => _dataSource.sendMessage(conversationId, text); +} diff --git a/apps/eva_mobile/lib/features/chat/domain/entities/chat_message_entity.dart b/apps/eva_mobile/lib/features/chat/domain/entities/chat_message_entity.dart new file mode 100644 index 0000000..7321e40 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/domain/entities/chat_message_entity.dart @@ -0,0 +1,19 @@ +class ChatMessageEntity { + final String id; + final String conversationId; + final bool isUser; + final String time; + final String text; + final String? emotionTag; + final String? emotionEmoji; + + const ChatMessageEntity({ + required this.id, + required this.conversationId, + required this.isUser, + required this.time, + required this.text, + this.emotionTag, + this.emotionEmoji, + }); +} diff --git a/apps/eva_mobile/lib/features/chat/domain/entities/conversation_entity.dart b/apps/eva_mobile/lib/features/chat/domain/entities/conversation_entity.dart new file mode 100644 index 0000000..1e68643 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/domain/entities/conversation_entity.dart @@ -0,0 +1,23 @@ +import 'chat_message_entity.dart'; + +class ConversationEntity { + final String id; + final String title; + final String date; + final String lastSessionTime; + final String moodTag; + final String moodEmoji; + final String summary; + final List messages; + + const ConversationEntity({ + required this.id, + required this.title, + required this.date, + required this.lastSessionTime, + required this.moodTag, + required this.moodEmoji, + required this.summary, + required this.messages, + }); +} 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 new file mode 100644 index 0000000..8372f24 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/domain/repositories/chat_repository.dart @@ -0,0 +1,8 @@ +import '../entities/chat_message_entity.dart'; +import '../entities/conversation_entity.dart'; + +abstract class ChatRepository { + Future> getConversations(); + Future getConversationById(String id); + Future sendMessage(String conversationId, String text); +} 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 new file mode 100644 index 0000000..a68fcd2 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart @@ -0,0 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/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'; + +final chatRepositoryProvider = Provider((ref) { + if (AppConfig.useMockData) { + return ChatRepositoryImpl(MockChatDataSource()); + } else { + return ChatRepositoryImpl(RemoteChatDataSource()); + } +}); + +final conversationsProvider = FutureProvider>((ref) async { + final repo = ref.watch(chatRepositoryProvider); + return repo.getConversations(); +}); + +final conversationDetailProvider = FutureProvider.family((ref, id) async { + final repo = ref.watch(chatRepositoryProvider); + return repo.getConversationById(id); +}); diff --git a/apps/eva_mobile/lib/features/dass/data/datasources/dass_datasource.dart b/apps/eva_mobile/lib/features/dass/data/datasources/dass_datasource.dart new file mode 100644 index 0000000..37bf112 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/data/datasources/dass_datasource.dart @@ -0,0 +1,176 @@ +import '../../../../core/network/api_client.dart'; +import '../models/dass_assessment_model.dart'; + +abstract class DASSDataSource { + Future getTodayAssessment(); + Future updateAssessment(List> items); + Future extractTodayAssessment(); +} + +class RemoteDASSDataSource implements DASSDataSource { + final ApiClient _apiClient; + + RemoteDASSDataSource({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + @override + Future getTodayAssessment() async { + final response = await _apiClient.get('/dass/today'); + return DASSAssessmentModel.fromJson(response as Map); + } + + @override + Future updateAssessment(List> items) async { + final response = await _apiClient.put( + '/dass/today', + body: {'items': items}, + ); + return DASSAssessmentModel.fromJson(response as Map); + } + + @override + Future extractTodayAssessment() async { + final response = await _apiClient.post('/dass/extract-today'); + return DASSAssessmentModel.fromJson(response as Map); + } +} + +class MockDASSDataSource implements DASSDataSource { + DASSAssessmentModel _mockData = _generateInitialMock(); + + static DASSAssessmentModel _generateInitialMock() { + final defaultQuestions = [ + {'item_id': 1, 'scale': 'stress', 'text': 'Saya merasa sulit untuk beristirahat atau menenangkan diri', 'score': 2, 'evidence': 'User: "Dari kemarin kepalaku tegang banget"', 'reason': 'Pengguna mengungkapkan rasa tegang di kepala yang menetap sejak kemarin sehingga sulit menenangkan pikiran.', 'conf': 0.85}, + {'item_id': 2, 'scale': 'anxiety', 'text': 'Saya menyadari mulut saya terasa kering', 'score': 0, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 3, 'scale': 'depression', 'text': 'Saya sama sekali tidak dapat merasakan perasaan positif', 'score': 1, 'evidence': 'User: "Hari ini rasanya flat aja, nggak semangat"', 'reason': 'Pengguna menyatakan suasana hati datar dan kehilangan antusiasme beraktivitas.', 'conf': 0.70}, + {'item_id': 4, 'scale': 'anxiety', 'text': 'Saya mengalami kesulitan bernapas (napas cepat)', 'score': 0, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 5, 'scale': 'depression', 'text': 'Saya merasa sulit berinisiatif untuk melakukan sesuatu', 'score': 1, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 6, 'scale': 'stress', 'text': 'Saya cenderung bereaksi berlebihan terhadap suatu situasi', 'score': 2, 'evidence': 'User: "Aku gampang marah sama hal-hal kecil"', 'reason': 'Pengguna merasakan sensitivitas emosional dan reaktivitas yang meningkat pada stimulus kecil.', 'conf': 0.80}, + {'item_id': 7, 'scale': 'anxiety', 'text': 'Saya mengalami gemetar (misalnya pada kedua tangan)', 'score': 0, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 8, 'scale': 'stress', 'text': 'Saya merasa menghabiskan banyak energi karena gugup', 'score': 1, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 9, 'scale': 'anxiety', 'text': 'Saya khawatir situasi di mana saya mungkin panik', 'score': 1, 'evidence': 'User: "Takut banget pas harus presentasi"', 'reason': 'Kecemasan performa dan rasa takut mengalami kepanikan saat evaluasi publik.', 'conf': 0.75}, + {'item_id': 10, 'scale': 'depression', 'text': 'Saya merasa tidak ada lagi hal baik di masa depan', 'score': 0, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 11, 'scale': 'stress', 'text': 'Saya mendapati diri saya mudah gelisah atau resah', 'score': 2, 'evidence': 'User: "Gelisah mikirin deadline tugas"', 'reason': 'Pengguna menunjukkan kegelisahan kognitif dan ketegangan akibat tuntutan tugas.', 'conf': 0.88}, + {'item_id': 12, 'scale': 'stress', 'text': 'Saya merasa sulit untuk rileks atau bersantai', 'score': 2, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 13, 'scale': 'depression', 'text': 'Saya merasa sedih, murung, dan tertekan', 'score': 1, 'evidence': 'User: "Sedih aja bawaannya"', 'reason': 'Suasana hati disforik atau rasa sedih yang menetap sepanjang hari.', 'conf': 0.65}, + {'item_id': 14, 'scale': 'stress', 'text': 'Saya tidak sabar menghadapi hal yang menghambat', 'score': 1, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 15, 'scale': 'anxiety', 'text': 'Saya merasa hampir panik', 'score': 0, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 16, 'scale': 'depression', 'text': 'Saya merasa tidak mampu antusias terhadap hal apa pun', 'score': 1, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 17, 'scale': 'depression', 'text': 'Saya merasa bahwa diri saya tidak berharga', 'score': 0, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 18, 'scale': 'stress', 'text': 'Saya merasa mudah tersinggung atau sensitif', 'score': 1, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 19, 'scale': 'anxiety', 'text': 'Saya menyadari detak jantung berdegup kencang', 'score': 1, 'evidence': 'User: "Deg-degan tiba-tiba"', 'reason': 'Gejala otonomik berupa palpitasi jantung yang disadari tanpa aktivitas fisik berlebih.', 'conf': 0.72}, + {'item_id': 20, 'scale': 'anxiety', 'text': 'Saya merasa takut tanpa alasan yang jelas', 'score': 0, 'evidence': null, 'reason': null, 'conf': 0.0}, + {'item_id': 21, 'scale': 'depression', 'text': 'Saya merasa bahwa hidup ini tidak berarti', 'score': 0, 'evidence': null, 'reason': null, 'conf': 0.0}, + ]; + + final items = defaultQuestions.map((q) { + return DASSItemModel( + itemId: q['item_id'] as int, + scale: q['scale'] as String, + questionText: q['text'] as String, + score: q['score'] as int, + evidence: q['evidence'] as String?, + reason: q['reason'] as String?, + confidence: (q['conf'] as num).toDouble(), + isUserEdited: false, + ); + }).toList(); + + return DASSAssessmentModel( + id: 'mock-dass-today', + userId: 'mock-user-123', + assessedDate: 'Hari Ini', + depressionScore: 8, // (1+1+1+1)*2 + anxietyScore: 4, // (1+1)*2 + stressScore: 22, // (2+2+1+2+2+1+1)*2 = 22 -> Moderate + depressionSeverity: 'Normal', + anxietySeverity: 'Normal', + stressSeverity: 'Moderate', + verifiedByUser: false, + status: 'auto_extracted', + items: items, + ); + } + + @override + Future getTodayAssessment() async { + await Future.delayed(const Duration(milliseconds: 300)); + return _mockData; + } + + @override + Future updateAssessment(List> items) async { + await Future.delayed(const Duration(milliseconds: 400)); + final currentItems = List.from(_mockData.items); + final map = {for (var it in items) it['item_id'] as int: it['score'] as int}; + + final updatedItems = currentItems.map((it) { + if (map.containsKey(it.itemId)) { + return DASSItemModel( + itemId: it.itemId, + scale: it.scale, + questionText: it.questionText, + score: map[it.itemId]!, + evidence: it.evidence, + confidence: it.confidence, + isUserEdited: true, + ); + } + return it; + }).toList(); + + int depRaw = 0, anxRaw = 0, strRaw = 0; + for (var it in updatedItems) { + if (it.scale == 'depression') depRaw += it.score; + if (it.scale == 'anxiety') anxRaw += it.score; + if (it.scale == 'stress') strRaw += it.score; + } + + final depScore = depRaw * 2; + final anxScore = anxRaw * 2; + final strScore = strRaw * 2; + + _mockData = DASSAssessmentModel( + id: _mockData.id, + userId: _mockData.userId, + assessedDate: _mockData.assessedDate, + depressionScore: depScore, + anxietyScore: anxScore, + stressScore: strScore, + depressionSeverity: _calcSeverity('depression', depScore), + anxietySeverity: _calcSeverity('anxiety', anxScore), + stressSeverity: _calcSeverity('stress', strScore), + verifiedByUser: true, + status: 'verified', + items: updatedItems, + ); + + return _mockData; + } + + static String _calcSeverity(String scale, int score) { + if (scale == 'depression') { + if (score >= 28) return 'Extremely Severe'; + if (score >= 21) return 'Severe'; + if (score >= 14) return 'Moderate'; + if (score >= 10) return 'Mild'; + return 'Normal'; + } else if (scale == 'anxiety') { + if (score >= 20) return 'Extremely Severe'; + if (score >= 15) return 'Severe'; + if (score >= 10) return 'Moderate'; + if (score >= 8) return 'Mild'; + return 'Normal'; + } else { + if (score >= 34) return 'Extremely Severe'; + if (score >= 26) return 'Severe'; + if (score >= 19) return 'Moderate'; + if (score >= 15) return 'Mild'; + return 'Normal'; + } + } + + @override + Future extractTodayAssessment() async { + return _mockData; + } +} diff --git a/apps/eva_mobile/lib/features/dass/data/models/dass_assessment_model.dart b/apps/eva_mobile/lib/features/dass/data/models/dass_assessment_model.dart new file mode 100644 index 0000000..a55a5d2 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/data/models/dass_assessment_model.dart @@ -0,0 +1,115 @@ +import '../../domain/entities/dass_assessment_entity.dart'; + +class DASSItemModel extends DASSItemEntity { + const DASSItemModel({ + required super.itemId, + required super.scale, + required super.questionText, + required super.score, + super.evidence, + super.reason, + required super.confidence, + required super.isUserEdited, + }); + + factory DASSItemModel.fromJson(Map json) { + return DASSItemModel( + itemId: json['item_id'] is int ? json['item_id'] : int.parse(json['item_id'].toString()), + scale: json['scale']?.toString() ?? 'stress', + questionText: json['question_text']?.toString() ?? '', + score: json['score'] is int ? json['score'] : int.tryParse(json['score']?.toString() ?? '0') ?? 0, + evidence: json['evidence']?.toString(), + reason: json['reason']?.toString(), + confidence: (json['confidence'] is num) + ? (json['confidence'] as num).toDouble() + : double.tryParse(json['confidence']?.toString() ?? '0.0') ?? 0.0, + isUserEdited: json['is_user_edited'] == true, + ); + } + + Map toJson() { + return { + 'item_id': itemId, + 'scale': scale, + 'question_text': questionText, + 'score': score, + 'evidence': evidence, + 'reason': reason, + 'confidence': confidence, + 'is_user_edited': isUserEdited, + }; + } +} + +class DASSAssessmentModel extends DASSAssessmentEntity { + const DASSAssessmentModel({ + super.id, + required super.userId, + required super.assessedDate, + required super.depressionScore, + required super.anxietyScore, + required super.stressScore, + required super.depressionSeverity, + required super.anxietySeverity, + required super.stressSeverity, + required super.verifiedByUser, + required super.status, + required super.items, + }); + + factory DASSAssessmentModel.fromJson(Map json) { + final rawItems = json['items'] as List? ?? []; + final itemsList = rawItems + .map((it) => DASSItemModel.fromJson(it as Map)) + .toList(); + + return DASSAssessmentModel( + id: json['id']?.toString(), + userId: json['user_id']?.toString() ?? '', + assessedDate: json['assessed_date']?.toString() ?? '', + depressionScore: json['depression_score'] is int + ? json['depression_score'] + : int.tryParse(json['depression_score']?.toString() ?? '0') ?? 0, + anxietyScore: json['anxiety_score'] is int + ? json['anxiety_score'] + : int.tryParse(json['anxiety_score']?.toString() ?? '0') ?? 0, + stressScore: json['stress_score'] is int + ? json['stress_score'] + : int.tryParse(json['stress_score']?.toString() ?? '0') ?? 0, + depressionSeverity: json['depression_severity']?.toString() ?? 'Normal', + anxietySeverity: json['anxiety_severity']?.toString() ?? 'Normal', + stressSeverity: json['stress_severity']?.toString() ?? 'Normal', + verifiedByUser: json['verified_by_user'] == true, + status: json['status']?.toString() ?? 'auto_extracted', + items: itemsList, + ); + } + + Map toJson() { + return { + 'id': id, + 'user_id': userId, + 'assessed_date': assessedDate, + 'depression_score': depressionScore, + 'anxiety_score': anxietyScore, + 'stress_score': stressScore, + 'depression_severity': depressionSeverity, + 'anxiety_severity': anxietySeverity, + 'stress_severity': stressSeverity, + 'verified_by_user': verifiedByUser, + 'status': status, + 'items': items.map((it) { + if (it is DASSItemModel) return it.toJson(); + return { + 'item_id': it.itemId, + 'scale': it.scale, + 'question_text': it.questionText, + 'score': it.score, + 'evidence': it.evidence, + 'confidence': it.confidence, + 'is_user_edited': it.isUserEdited, + }; + }).toList(), + }; + } +} diff --git a/apps/eva_mobile/lib/features/dass/data/repositories/dass_repository_impl.dart b/apps/eva_mobile/lib/features/dass/data/repositories/dass_repository_impl.dart new file mode 100644 index 0000000..8480f1b --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/data/repositories/dass_repository_impl.dart @@ -0,0 +1,24 @@ +import '../../domain/entities/dass_assessment_entity.dart'; +import '../../domain/repositories/dass_repository.dart'; +import '../datasources/dass_datasource.dart'; + +class DASSRepositoryImpl implements DASSRepository { + final DASSDataSource _dataSource; + + DASSRepositoryImpl(this._dataSource); + + @override + Future getTodayAssessment() { + return _dataSource.getTodayAssessment(); + } + + @override + Future updateAssessment(List> items) { + return _dataSource.updateAssessment(items); + } + + @override + Future extractTodayAssessment() { + return _dataSource.extractTodayAssessment(); + } +} diff --git a/apps/eva_mobile/lib/features/dass/domain/entities/dass_assessment_entity.dart b/apps/eva_mobile/lib/features/dass/domain/entities/dass_assessment_entity.dart new file mode 100644 index 0000000..071c127 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/domain/entities/dass_assessment_entity.dart @@ -0,0 +1,103 @@ +class DASSItemEntity { + final int itemId; + final String scale; // 'stress' | 'anxiety' | 'depression' + final String questionText; + final int score; // 0, 1, 2, 3 + final String? evidence; + final String? reason; + final double confidence; + final bool isUserEdited; + + const DASSItemEntity({ + required this.itemId, + required this.scale, + required this.questionText, + required this.score, + this.evidence, + this.reason, + required this.confidence, + required this.isUserEdited, + }); + + DASSItemEntity copyWith({ + int? itemId, + String? scale, + String? questionText, + int? score, + String? evidence, + String? reason, + double? confidence, + bool? isUserEdited, + }) { + return DASSItemEntity( + itemId: itemId ?? this.itemId, + scale: scale ?? this.scale, + questionText: questionText ?? this.questionText, + score: score ?? this.score, + evidence: evidence ?? this.evidence, + reason: reason ?? this.reason, + confidence: confidence ?? this.confidence, + isUserEdited: isUserEdited ?? this.isUserEdited, + ); + } +} + +class DASSAssessmentEntity { + final String? id; + final String userId; + final String assessedDate; + final int depressionScore; + final int anxietyScore; + final int stressScore; + final String depressionSeverity; + final String anxietySeverity; + final String stressSeverity; + final bool verifiedByUser; + final String status; + final List items; + + const DASSAssessmentEntity({ + this.id, + required this.userId, + required this.assessedDate, + required this.depressionScore, + required this.anxietyScore, + required this.stressScore, + required this.depressionSeverity, + required this.anxietySeverity, + required this.stressSeverity, + required this.verifiedByUser, + required this.status, + required this.items, + }); + + DASSAssessmentEntity copyWith({ + String? id, + String? userId, + String? assessedDate, + int? depressionScore, + int? anxietyScore, + int? stressScore, + String? depressionSeverity, + String? anxietySeverity, + String? stressSeverity, + bool? verifiedByUser, + String? status, + List? items, + }) { + return DASSAssessmentEntity( + id: id ?? this.id, + userId: userId ?? this.userId, + assessedDate: assessedDate ?? this.assessedDate, + depressionScore: depressionScore ?? this.depressionScore, + anxietyScore: anxietyScore ?? this.anxietyScore, + stressScore: stressScore ?? this.stressScore, + depressionSeverity: depressionSeverity ?? this.depressionSeverity, + anxietySeverity: anxietySeverity ?? this.anxietySeverity, + stressSeverity: stressSeverity ?? this.stressSeverity, + verifiedByUser: verifiedByUser ?? this.verifiedByUser, + status: status ?? this.status, + items: items ?? this.items, + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/domain/repositories/dass_repository.dart b/apps/eva_mobile/lib/features/dass/domain/repositories/dass_repository.dart new file mode 100644 index 0000000..6de3768 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/domain/repositories/dass_repository.dart @@ -0,0 +1,7 @@ +import '../entities/dass_assessment_entity.dart'; + +abstract class DASSRepository { + Future getTodayAssessment(); + Future updateAssessment(List> items); + Future 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 new file mode 100644 index 0000000..379048d --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/providers/dass_provider.dart @@ -0,0 +1,140 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/config/app_config.dart'; +import '../../data/datasources/dass_datasource.dart'; +import '../../data/repositories/dass_repository_impl.dart'; +import '../../domain/entities/dass_assessment_entity.dart'; +import '../../domain/repositories/dass_repository.dart'; +import '../../../monitoring/presentation/providers/monitoring_provider.dart'; + +final dassRepositoryProvider = Provider((ref) { + if (AppConfig.useMockData) { + return DASSRepositoryImpl(MockDASSDataSource()); + } else { + return DASSRepositoryImpl(RemoteDASSDataSource()); + } +}); + +class DASSAssessmentState { + final AsyncValue assessment; + final Map stagedScores; + final bool isSaving; + final String? successMessage; + final String? errorMessage; + + const DASSAssessmentState({ + required this.assessment, + this.stagedScores = const {}, + this.isSaving = false, + this.successMessage, + this.errorMessage, + }); + + DASSAssessmentState copyWith({ + AsyncValue? assessment, + Map? stagedScores, + bool? isSaving, + String? successMessage, + String? errorMessage, + }) { + return DASSAssessmentState( + assessment: assessment ?? this.assessment, + stagedScores: stagedScores ?? this.stagedScores, + isSaving: isSaving ?? this.isSaving, + successMessage: successMessage, + errorMessage: errorMessage, + ); + } +} + +class DASSAssessmentNotifier extends StateNotifier { + final DASSRepository _repository; + final Ref _ref; + + DASSAssessmentNotifier(this._repository, this._ref) + : super(const DASSAssessmentState(assessment: AsyncValue.loading())) { + loadTodayAssessment(); + } + + Future loadTodayAssessment() async { + state = state.copyWith(assessment: const AsyncValue.loading(), errorMessage: null); + try { + final result = await _repository.getTodayAssessment(); + // Initialize staged scores with current scores + final initialStaged = {for (var it in result.items) it.itemId: it.score}; + state = state.copyWith( + assessment: AsyncValue.data(result), + stagedScores: initialStaged, + ); + } catch (e, st) { + state = state.copyWith(assessment: AsyncValue.error(e, st)); + } + } + + void updateScore(int itemId, int newScore) { + final updated = Map.from(state.stagedScores); + updated[itemId] = newScore; + state = state.copyWith(stagedScores: updated); + } + + Future saveAssessment() async { + if (state.isSaving) return false; + state = state.copyWith(isSaving: true, errorMessage: null, successMessage: null); + + try { + final payload = state.stagedScores.entries + .map((e) => {'item_id': e.key, 'score': e.value}) + .toList(); + + final updatedEntity = await _repository.updateAssessment(payload); + + final newStaged = {for (var it in updatedEntity.items) it.itemId: it.score}; + state = state.copyWith( + assessment: AsyncValue.data(updatedEntity), + stagedScores: newStaged, + isSaving: false, + successMessage: 'Koreksi DASS-21 berhasil disimpan & ritem emosional diselaraskan!', + ); + + // Invalidate monitoring provider agar kartu risiko di halaman tren ikut ter-refresh seketika + _ref.invalidate(monitoringDataProvider); + return true; + } catch (e) { + state = state.copyWith( + isSaving: false, + errorMessage: 'Gagal menyimpan perubahan: $e', + ); + return false; + } + } + + Future extractTodayAssessment() async { + state = state.copyWith( + assessment: const AsyncValue.loading(), + errorMessage: null, + successMessage: null, + ); + try { + final result = await _repository.extractTodayAssessment(); + final initialStaged = {for (var it in result.items) it.itemId: it.score}; + state = state.copyWith( + assessment: AsyncValue.data(result), + stagedScores: initialStaged, + successMessage: 'Hasil DASS-21 berhasil diekstrak dari percakapan hari ini!', + ); + _ref.invalidate(monitoringDataProvider); + return true; + } catch (e, st) { + state = state.copyWith( + assessment: AsyncValue.error(e, st), + errorMessage: 'Gagal mengekstrak DASS: $e', + ); + return false; + } + } +} + +final dassAssessmentNotifierProvider = + StateNotifierProvider((ref) { + final repo = ref.watch(dassRepositoryProvider); + return DASSAssessmentNotifier(repo, ref); +}); 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 new file mode 100644 index 0000000..6360d0a --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_form_card.dart @@ -0,0 +1,468 @@ +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/glass_card.dart'; +import '../../../../widgets/skeleton_shimmer.dart'; +import '../providers/dass_provider.dart'; +import '../../domain/entities/dass_assessment_entity.dart'; + +class DASSFormCard extends ConsumerStatefulWidget { + const DASSFormCard({super.key}); + + @override + ConsumerState createState() => _DASSFormCardState(); +} + +class _DASSFormCardState extends ConsumerState { + bool _isExpanded = false; + + Color _getScaleColor(String scale) { + switch (scale.toLowerCase()) { + case 'depression': + return const Color(0xFF74B9FF); + case 'anxiety': + return const Color(0xFF6C63FF); + case 'stress': + default: + return const Color(0xFFFF7675); + } + } + + String _getScaleLabel(String scale) { + switch (scale.toLowerCase()) { + case 'depression': + return 'Depresi'; + case 'anxiety': + return 'Kecemasan'; + case 'stress': + default: + return 'Stres'; + } + } + + @override + Widget build(BuildContext context) { + final dassState = ref.watch(dassAssessmentNotifierProvider); + final notifier = ref.read(dassAssessmentNotifierProvider.notifier); + + return dassState.assessment.when( + loading: () => SkeletonShimmerHost( + child: GlassCard( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + SkeletonCircle(size: 32), + SizedBox(width: 12), + SkeletonLine(width: 160, height: 16), + Spacer(), + SkeletonBox(width: 60, height: 22, borderRadius: 6), + ], + ), + const SizedBox(height: 16), + const SkeletonLine(width: double.infinity, height: 13), + const SizedBox(height: 8), + const SkeletonLine(width: 220, height: 13), + ], + ), + ), + ), + error: (err, _) => GlassCard( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + const Icon(Icons.error_outline, color: Colors.redAccent, size: 32), + const SizedBox(height: 8), + Text( + 'Gagal memuat asesmen DASS-21: $err', + style: GoogleFonts.plusJakartaSans(fontSize: 13, color: Colors.redAccent), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => notifier.loadTodayAssessment(), + child: const Text('Coba Lagi'), + ), + ], + ), + ), + data: (assessment) { + final isVerified = assessment.verifiedByUser; + final staged = dassState.stagedScores; + final bool hasChanges = + assessment.items.any((it) => (staged[it.itemId] ?? it.score) != it.score); + + return GlassCard( + padding: const EdgeInsets.all(18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFE8F0FE), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.psychology_outlined, + color: Color(0xFF1A73E8), + size: 26, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Asesmen DASS-21', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w700, + color: const Color(0xFF1E1B4B), + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isVerified ? const Color(0xFFE6F4EA) : const Color(0xFFFFF0D4), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + isVerified ? 'Diverifikasi User' : 'Estimasi AI', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isVerified ? const Color(0xFF137333) : const Color(0xFFB06000), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Deteksi otomatis dari obrolan santai & koreksi mandiri.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: const Color(0xFF6B7280), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // Subscale Scores Badges + Row( + children: [ + _buildSubscalePill('Stres', assessment.stressScore, assessment.stressSeverity, const Color(0xFFFF7675)), + const SizedBox(width: 8), + _buildSubscalePill('Kecemasan', assessment.anxietyScore, assessment.anxietySeverity, const Color(0xFF6C63FF)), + const SizedBox(width: 8), + _buildSubscalePill('Depresi', assessment.depressionScore, assessment.depressionSeverity, const Color(0xFF74B9FF)), + ], + ), + const SizedBox(height: 16), + + // Expand / Collapse Action + InkWell( + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _isExpanded ? 'Tutup Daftar 21 Butir Soal' : 'Lihat & Edit 21 Butir Soal', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w600, + color: const Color(0xFF374151), + ), + ), + const SizedBox(width: 6), + Icon( + _isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, + size: 20, + color: const Color(0xFF374151), + ), + ], + ), + ), + ), + + // Expanded 21 Items Form + if (_isExpanded) ...[ + const SizedBox(height: 16), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: assessment.items.length, + separatorBuilder: (context, index) => const Divider(height: 24, color: Color(0xFFE5E7EB)), + itemBuilder: (context, index) { + final item = assessment.items[index]; + final currentScore = staged[item.itemId] ?? item.score; + return _buildQuestionItem(item, currentScore, notifier); + }, + ), + // Animated Slide-Up Save Button (only visible when changes exist) + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + child: hasChanges + ? Padding( + padding: const EdgeInsets.only(top: 20.0), + child: AnimatedSlide( + offset: hasChanges ? Offset.zero : const Offset(0, 0.5), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + child: SizedBox( + width: double.infinity, + height: 46, + child: ElevatedButton( + onPressed: dassState.isSaving + ? null + : () async { + final scaffoldMessenger = ScaffoldMessenger.of(context); + final success = await notifier.saveAssessment(); + if (!mounted) return; + if (success) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + 'Koreksi DASS-21 berhasil disimpan & ritem emosional diselaraskan!', + style: GoogleFonts.plusJakartaSans(), + ), + backgroundColor: const Color(0xFF10B981), + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: dassState.isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : Text( + 'Simpan Koreksi', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ], + ), + ); + }, + ); + } + + Widget _buildSubscalePill(String title, int score, String severity, Color color) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.2)), + ), + child: Column( + children: [ + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + const SizedBox(height: 2), + Text( + '$score', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w700, + color: const Color(0xFF1E1B4B), + ), + ), + Text( + severity, + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + color: const Color(0xFF6B7280), + ), + ), + ], + ), + ), + ); + } + + Widget _buildQuestionItem(DASSItemEntity item, int currentScore, DASSAssessmentNotifier notifier) { + final scaleColor = _getScaleColor(item.scale); + final scaleLabel = _getScaleLabel(item.scale); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: scaleColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Item ${item.itemId} â€ĸ $scaleLabel', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: scaleColor, + ), + ), + ), + if (item.isUserEdited) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFE0E7FF), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Telah Diedit', + style: GoogleFonts.plusJakartaSans( + fontSize: 9, + fontWeight: FontWeight.w600, + color: const Color(0xFF4338CA), + ), + ), + ), + ], + ], + ), + const SizedBox(height: 6), + Text( + item.questionText, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w500, + color: const Color(0xFF1F2937), + height: 1.3, + ), + ), + + // Bukti kutipan dari percakapan AI jika ada + if (item.evidence != null && item.evidence!.isNotEmpty) ...[ + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFF9FAFB), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.format_quote_rounded, size: 16, color: Color(0xFF9CA3AF)), + const SizedBox(width: 4), + Expanded( + child: Text( + item.evidence!, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontStyle: FontStyle.italic, + color: const Color(0xFF4B5563), + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 10), + + // Pilihan Likert 0-3 + Row( + children: [ + _buildLikertOption(0, '0: Tdk Pernah', currentScore == 0, () => notifier.updateScore(item.itemId, 0)), + const SizedBox(width: 6), + _buildLikertOption(1, '1: Kadang', currentScore == 1, () => notifier.updateScore(item.itemId, 1)), + const SizedBox(width: 6), + _buildLikertOption(2, '2: Sering', currentScore == 2, () => notifier.updateScore(item.itemId, 2)), + const SizedBox(width: 6), + _buildLikertOption(3, '3: Selalu', currentScore == 3, () => notifier.updateScore(item.itemId, 3)), + ], + ), + ], + ); + } + + Widget _buildLikertOption(int score, String label, bool isSelected, VoidCallback onTap) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFD1D5DB), + ), + ), + child: Center( + child: Text( + label, + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? Colors.white : const Color(0xFF4B5563), + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/data/datasources/diary_datasource.dart b/apps/eva_mobile/lib/features/diary/data/datasources/diary_datasource.dart new file mode 100644 index 0000000..6346f05 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/data/datasources/diary_datasource.dart @@ -0,0 +1,10 @@ +import '../models/diary_entry_model.dart'; + +abstract class DiaryDataSource { + Future> getDiaryEntries({String? moodFilter, String? searchQuery}); + Future getDiaryEntryById(String id); + Future getTodayDiary(); + Future generateTodayDiary(); + Future createDiaryEntry(String content, String moodTag); + Future deleteDiaryEntry(String id); +} 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 new file mode 100644 index 0000000..940245e --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/data/datasources/mock_diary_datasource.dart @@ -0,0 +1,189 @@ +import 'diary_datasource.dart'; +import '../models/diary_entry_model.dart'; +import '../models/risk_warning_model.dart'; + +class MockDiaryDataSource implements DiaryDataSource { + final List _entries = [ + const DiaryEntryModel( + id: 'diary_samsul_today_00', + title: 'Jurnal Refleksi Hari Ini', + date: '1 September 2026', + sessionCount: 1, + lastSessionTime: '08:15 AM', + moodTag: 'Bahagia 😃', + moodEmoji: '😃', + summary: 'Samsul merasa lebih semangat dan positif setelah berdiskusi mengenai target pribadi dan meditasi pagi bersama EVA.', + aiInsight: 'Progres emosional Samsul menunjukkan peningkatan kebahagiaan dan motivasi positif.', + importantEvents: [ + '[Sesi #1] Afirmasi positif pagi dan perencanaan aktivitas produktif.', + ], + emotionalReflection: 'Merasa optimis menghadapi tantangan hari ini.', + sessions: [ + DiarySessionSummaryModel( + id: 'sess_samsul_0', + title: 'Afirmasi Pagi', + time: '08:15 AM', + moodTag: 'Bahagia 😃', + moodEmoji: '😃', + ), + ], + ), + const DiaryEntryModel( + id: 'diary_samsul_normal_02', + title: 'Catatan Refleksi Emosi Harian', + date: '31 Agustus 2026', + sessionCount: 1, + lastSessionTime: '20:15 PM', + moodTag: 'Tenang 😌', + moodEmoji: '😌', + summary: 'Samsul merasakan ketenangan setelah menyelesaikan sesi konsultasi mengenai manajemen waktu dan relaksasi pikiran.', + aiInsight: 'Kondisi emosional Samsul tergolong stabil dan mengalami pemulihan energi emosional yang baik.', + importantEvents: [ + '[Sesi #1] Refleksi mengenai rutinitas harian dan teknik olah napas.', + ], + emotionalReflection: 'Merasa lebih lega dan siap melanjutkan aktivitas esok hari.', + sessions: [ + DiarySessionSummaryModel( + id: 'sess_samsul_3', + title: 'Refleksi Rutinitas Malam', + time: '20:15 PM', + moodTag: 'Tenang 😌', + moodEmoji: '😌', + ), + ], + ), + const DiaryEntryModel( + id: 'diary_samsul_anxiety_03', + title: 'Diskusi Beban Kerja & Stres', + date: '30 Agustus 2026', + sessionCount: 1, + lastSessionTime: '19:40 PM', + moodTag: 'Cemas 😰', + moodEmoji: '😰', + summary: 'Samsul mengutarakan rasa khawatir terkait tenggat waktu pekerjaan dan mencoba menerapkan latihan olah napas 4-7-8.', + aiInsight: 'Terdeteksi peningkatan kecemasan sedang. EVA merekomendasikan jeda istirahat singkat.', + importantEvents: [ + '[Sesi #1] Diskusi penanganan kecemasan akademis/pekerjaan.', + ], + emotionalReflection: 'Mulai memahami pemicu kecemasan dan mengelola fokus secara bertahap.', + sessions: [ + DiarySessionSummaryModel( + id: 'sess_samsul_4', + title: 'Konsultasi Manajemen Kecemasan', + time: '19:40 PM', + moodTag: 'Cemas 😰', + moodEmoji: '😰', + ), + ], + ), + const DiaryEntryModel( + id: 'diary_samsul_emergency_01', + title: 'Jurnal Emosional Krisis — Samsul', + date: '28 Agustus 2026', + sessionCount: 2, + lastSessionTime: '07:30 AM', + moodTag: 'Darurat 🚨', + moodEmoji: '🚨', + summary: 'Samsul mengutarakan rasa kecemasan dan kelelahan mental ekstrem terkait beban kerja dan kondisi krisis. Terdeteksi indikasi krisis emosional tinggi.', + riskWarning: RiskWarningModel( + detected: true, + type: 'High Risk / Krisis', + title: 'PERINGATAN KRISIS EMOSIONAL', + 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).', + importantEvents: [ + '[Sesi #1] Samsul menyampaikan keluhan kelelahan fisik dan kecemasan mendalam.', + '[Sesi #2] Terdeteksi puncak stres emosional tinggi — Protokol Krisis dipicu.', + ], + emotionalReflection: 'Samsul menganjurkan diri untuk beristirahat penuh dan menghubungi Kontak Darurat Utama atau Konselor Profesional.', + sessions: [ + DiarySessionSummaryModel( + id: 'sess_samsul_1', + title: 'Refleksi Stres Pagi', + time: '07:15 AM', + moodTag: 'Stres đŸ’Ĩ', + moodEmoji: '😰', + ), + DiarySessionSummaryModel( + id: 'sess_samsul_2', + title: 'Panggilan Suara Krisis', + time: '07:30 AM', + moodTag: 'Darurat 🚨', + moodEmoji: '🚨', + ), + ], + ), + ]; + + @override + Future> getDiaryEntries({String? moodFilter, String? searchQuery}) async { + await Future.delayed(const Duration(milliseconds: 300)); + var results = List.from(_entries); + + if (moodFilter != null && moodFilter != 'Semua') { + final cleanFilter = moodFilter.replaceAll(RegExp(r'[^\w\s]'), '').trim().toLowerCase(); + results = results.where((e) => e.moodTag.toLowerCase().contains(cleanFilter)).toList(); + } + + if (searchQuery != null && searchQuery.isNotEmpty) { + final q = searchQuery.toLowerCase(); + results = results.where((e) => + e.title.toLowerCase().contains(q) || + e.summary.toLowerCase().contains(q) + ).toList(); + } + + return results; + } + + @override + Future getDiaryEntryById(String id) async { + await Future.delayed(const Duration(milliseconds: 200)); + try { + return _entries.firstWhere((e) => e.id == id); + } catch (_) { + return _entries.isNotEmpty ? _entries.first : null; + } + } + + @override + Future getTodayDiary() async { + await Future.delayed(const Duration(milliseconds: 200)); + return _entries.isNotEmpty ? _entries.first : null; + } + + @override + Future generateTodayDiary() async { + await Future.delayed(const Duration(milliseconds: 300)); + return _entries.isNotEmpty ? _entries.first : null; + } + + @override + Future createDiaryEntry(String content, String moodTag) async { + await Future.delayed(const Duration(milliseconds: 400)); + final newEntry = DiaryEntryModel( + id: DateTime.now().millisecondsSinceEpoch.toString(), + title: 'Jurnal ${DateTime.now().day}/${DateTime.now().month}', + date: '${DateTime.now().day} Oktober ${DateTime.now().year}', + sessionCount: 1, + lastSessionTime: '${DateTime.now().hour}:${DateTime.now().minute}', + moodTag: moodTag, + moodEmoji: '📝', + summary: content, + aiInsight: 'Analisis AI sedang diproses untuk jurnal baru ini.', + importantEvents: ['[Sesi #1] Catatan mandiri'], + emotionalReflection: 'Refleksi awal setelah menulis jurnal.', + sessions: const [], + ); + _entries.insert(0, newEntry); + return newEntry; + } + + @override + Future deleteDiaryEntry(String id) async { + await Future.delayed(const Duration(milliseconds: 200)); + _entries.removeWhere((e) => e.id == id); + } +} diff --git a/apps/eva_mobile/lib/features/diary/data/datasources/remote_diary_datasource.dart b/apps/eva_mobile/lib/features/diary/data/datasources/remote_diary_datasource.dart new file mode 100644 index 0000000..f3e020b --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/data/datasources/remote_diary_datasource.dart @@ -0,0 +1,55 @@ +import 'diary_datasource.dart'; +import '../models/diary_entry_model.dart'; +import '../../../../core/network/api_client.dart'; + +class RemoteDiaryDataSource implements DiaryDataSource { + final ApiClient _apiClient; + + RemoteDiaryDataSource({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + @override + Future> getDiaryEntries({String? moodFilter, String? searchQuery}) async { + final queryParams = {}; + if (moodFilter != null) queryParams['mood'] = moodFilter; + if (searchQuery != null) queryParams['search'] = searchQuery; + + final response = await _apiClient.get('/diaries', queryParams: queryParams.isNotEmpty ? queryParams : null); + final list = response as List? ?? []; + return list.map((e) => DiaryEntryModel.fromJson(e)).toList(); + } + + @override + Future getDiaryEntryById(String id) async { + final response = await _apiClient.get('/diaries/$id'); + if (response == null) return null; + return DiaryEntryModel.fromJson(response); + } + + @override + Future getTodayDiary() async { + final response = await _apiClient.get('/diaries/today'); + if (response == null) return null; + return DiaryEntryModel.fromJson(response); + } + + @override + Future generateTodayDiary() async { + final response = await _apiClient.post('/diaries/generate'); + if (response == null) return null; + return DiaryEntryModel.fromJson(response); + } + + @override + Future createDiaryEntry(String content, String moodTag) async { + final response = await _apiClient.post('/diaries', body: { + 'content': content, + 'mood_tag': moodTag, + }); + return DiaryEntryModel.fromJson(response); + } + + @override + Future deleteDiaryEntry(String id) async { + await _apiClient.delete('/diaries/$id'); + } +} diff --git a/apps/eva_mobile/lib/features/diary/data/models/diary_entry_model.dart b/apps/eva_mobile/lib/features/diary/data/models/diary_entry_model.dart new file mode 100644 index 0000000..cd888af --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/data/models/diary_entry_model.dart @@ -0,0 +1,90 @@ +import '../../domain/entities/diary_entry_entity.dart'; +import 'risk_warning_model.dart'; + +class DiarySessionSummaryModel extends DiarySessionSummaryEntity { + const DiarySessionSummaryModel({ + required super.id, + required super.title, + required super.time, + required super.moodTag, + required super.moodEmoji, + }); + + factory DiarySessionSummaryModel.fromJson(Map json) { + return DiarySessionSummaryModel( + id: json['id']?.toString() ?? '', + title: json['title']?.toString() ?? '', + time: json['time']?.toString() ?? '', + moodTag: json['moodTag']?.toString() ?? json['mood_tag']?.toString() ?? '', + moodEmoji: json['moodEmoji']?.toString() ?? json['mood_emoji']?.toString() ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'time': time, + 'moodTag': moodTag, + 'moodEmoji': moodEmoji, + }; + } +} + +class DiaryEntryModel extends DiaryEntryEntity { + const DiaryEntryModel({ + required super.id, + required super.title, + required super.date, + required super.sessionCount, + required super.lastSessionTime, + required super.moodTag, + required super.moodEmoji, + required super.summary, + super.riskWarning, + required super.aiInsight, + required super.importantEvents, + required super.emotionalReflection, + required super.sessions, + }); + + factory DiaryEntryModel.fromJson(Map json) { + final rawRisk = json['riskWarning'] ?? json['risk_warning']; + final List rawEvents = (json['importantEvents'] as List?) ?? (json['important_events'] as List?) ?? []; + final List rawSessions = (json['sessions'] as List?) ?? []; + + return DiaryEntryModel( + id: json['id']?.toString() ?? '', + title: json['title']?.toString() ?? '', + date: json['date']?.toString() ?? '', + sessionCount: json['sessionCount'] ?? json['session_count'] ?? 1, + lastSessionTime: json['lastSessionTime']?.toString() ?? json['last_session_time']?.toString() ?? '', + moodTag: json['moodTag']?.toString() ?? json['mood_tag']?.toString() ?? '', + moodEmoji: json['moodEmoji']?.toString() ?? json['mood_emoji']?.toString() ?? '', + summary: json['summary']?.toString() ?? '', + riskWarning: rawRisk != null ? RiskWarningModel.fromJson(Map.from(rawRisk as Map)) : null, + aiInsight: json['aiInsight']?.toString() ?? json['ai_insight']?.toString() ?? '', + importantEvents: rawEvents.map((e) => e.toString()).toList(), + emotionalReflection: json['emotionalReflection']?.toString() ?? json['emotional_reflection']?.toString() ?? '', + sessions: rawSessions.map((s) => DiarySessionSummaryModel.fromJson(Map.from(s as Map))).toList(), + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'date': date, + 'sessionCount': sessionCount, + 'lastSessionTime': lastSessionTime, + 'moodTag': moodTag, + 'moodEmoji': moodEmoji, + 'summary': summary, + 'riskWarning': riskWarning != null ? (riskWarning as RiskWarningModel).toJson() : null, + 'aiInsight': aiInsight, + 'importantEvents': importantEvents, + 'emotionalReflection': emotionalReflection, + 'sessions': sessions.map((s) => (s as DiarySessionSummaryModel).toJson()).toList(), + }; + } +} diff --git a/apps/eva_mobile/lib/features/diary/data/models/risk_warning_model.dart b/apps/eva_mobile/lib/features/diary/data/models/risk_warning_model.dart new file mode 100644 index 0000000..ee00332 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/data/models/risk_warning_model.dart @@ -0,0 +1,31 @@ +import '../../domain/entities/risk_warning_entity.dart'; + +class RiskWarningModel extends RiskWarningEntity { + const RiskWarningModel({ + required super.detected, + required super.type, + required super.title, + required super.level, + required super.message, + }); + + factory RiskWarningModel.fromJson(Map json) { + return RiskWarningModel( + detected: json['detected'] ?? false, + type: json['type']?.toString() ?? '', + title: json['title']?.toString() ?? '', + level: json['level']?.toString() ?? '', + message: json['message']?.toString() ?? '', + ); + } + + Map toJson() { + return { + 'detected': detected, + 'type': type, + 'title': title, + 'level': level, + 'message': message, + }; + } +} 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 new file mode 100644 index 0000000..cf696fb --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/data/repositories/diary_repository_impl.dart @@ -0,0 +1,23 @@ +import '../../domain/repositories/diary_repository.dart'; +import '../../domain/entities/diary_entry_entity.dart'; +import '../datasources/diary_datasource.dart'; + +class DiaryRepositoryImpl implements DiaryRepository { + final DiaryDataSource _dataSource; + + DiaryRepositoryImpl(this._dataSource); + + @override + Future> getDiaryEntries({String? moodFilter, String? searchQuery}) => + _dataSource.getDiaryEntries(moodFilter: moodFilter, searchQuery: searchQuery); + + @override + Future getDiaryEntryById(String id) => _dataSource.getDiaryEntryById(id); + + @override + Future createDiaryEntry(String content, String moodTag) => + _dataSource.createDiaryEntry(content, moodTag); + + @override + Future deleteDiaryEntry(String id) => _dataSource.deleteDiaryEntry(id); +} diff --git a/apps/eva_mobile/lib/features/diary/domain/entities/diary_entry_entity.dart b/apps/eva_mobile/lib/features/diary/domain/entities/diary_entry_entity.dart new file mode 100644 index 0000000..935e21b --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/domain/entities/diary_entry_entity.dart @@ -0,0 +1,49 @@ +import 'risk_warning_entity.dart'; + +class DiarySessionSummaryEntity { + final String id; + final String title; + final String time; + final String moodTag; + final String moodEmoji; + + const DiarySessionSummaryEntity({ + required this.id, + required this.title, + required this.time, + required this.moodTag, + required this.moodEmoji, + }); +} + +class DiaryEntryEntity { + final String id; + final String title; + final String date; + final int sessionCount; + final String lastSessionTime; + final String moodTag; + final String moodEmoji; + final String summary; + final RiskWarningEntity? riskWarning; + final String aiInsight; + final List importantEvents; + final String emotionalReflection; + final List sessions; + + const DiaryEntryEntity({ + required this.id, + required this.title, + required this.date, + required this.sessionCount, + required this.lastSessionTime, + required this.moodTag, + required this.moodEmoji, + required this.summary, + this.riskWarning, + required this.aiInsight, + required this.importantEvents, + required this.emotionalReflection, + required this.sessions, + }); +} diff --git a/apps/eva_mobile/lib/features/diary/domain/entities/risk_warning_entity.dart b/apps/eva_mobile/lib/features/diary/domain/entities/risk_warning_entity.dart new file mode 100644 index 0000000..a36237c --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/domain/entities/risk_warning_entity.dart @@ -0,0 +1,15 @@ +class RiskWarningEntity { + final bool detected; + final String type; + final String title; + final String level; + final String message; + + const RiskWarningEntity({ + required this.detected, + required this.type, + required this.title, + required this.level, + required this.message, + }); +} 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 new file mode 100644 index 0000000..553bb8e --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/domain/repositories/diary_repository.dart @@ -0,0 +1,8 @@ +import '../entities/diary_entry_entity.dart'; + +abstract class DiaryRepository { + Future> getDiaryEntries({String? moodFilter, String? searchQuery}); + Future getDiaryEntryById(String id); + Future createDiaryEntry(String content, String moodTag); + Future 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 new file mode 100644 index 0000000..21493ea --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart @@ -0,0 +1,30 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/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'; + +final diaryRepositoryProvider = Provider((ref) { + if (AppConfig.useMockData) { + return DiaryRepositoryImpl(MockDiaryDataSource()); + } else { + return DiaryRepositoryImpl(RemoteDiaryDataSource()); + } +}); + +final selectedMoodFilterProvider = StateProvider((ref) => 'Semua'); +final searchQueryProvider = StateProvider((ref) => ''); + +final diaryEntriesProvider = FutureProvider>((ref) async { + final repo = ref.watch(diaryRepositoryProvider); + final filter = ref.watch(selectedMoodFilterProvider); + final search = ref.watch(searchQueryProvider); + return repo.getDiaryEntries(moodFilter: filter, searchQuery: search); +}); + +final diaryDetailProvider = FutureProvider.family((ref, id) async { + final repo = ref.watch(diaryRepositoryProvider); + return repo.getDiaryEntryById(id); +}); diff --git a/apps/eva_mobile/lib/features/monitoring/data/datasources/mock_monitoring_datasource.dart b/apps/eva_mobile/lib/features/monitoring/data/datasources/mock_monitoring_datasource.dart new file mode 100644 index 0000000..4129497 --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/data/datasources/mock_monitoring_datasource.dart @@ -0,0 +1,106 @@ +import 'monitoring_datasource.dart'; +import '../models/monitoring_data_model.dart'; + +class MockMonitoringDataSource implements MonitoringDataSource { + final Map _mockData = { + 'today': const MonitoringDataModel( + periodKey: 'today', + periodLabel: 'Hari Ini', + summary: 'Evaluasi 3 sesi suara hari ini menunjukkan kecemasan di pagi hari yang mereda di sore hari setelah jeda istirahat.', + emotionalCenter: EmotionalCenterModel( + status: 'Cukup (Kecenderungan Membaik)', + level: 3, + description: 'Keseimbangan emosi mulai pulih di penghujung hari.', + textColorHex: '#F57F17', + ), + risks: [ + RiskIndicatorModel( + name: 'Risiko Stres', + type: 'stress', + percent: 0.70, + levelLabel: 'Tinggi (70%)', + colorHex: '#D32F2F', + badgeBgHex: '#FFDCDD', + ), + RiskIndicatorModel( + name: 'Risiko Anxiety', + type: 'anxiety', + percent: 0.65, + levelLabel: 'Sedang-Tinggi (65%)', + colorHex: '#E57373', + badgeBgHex: '#FFEBEE', + ), + RiskIndicatorModel( + name: 'Risiko Depresi', + type: 'depresi', + percent: 0.30, + levelLabel: 'Rendah-Sedang (30%)', + colorHex: '#489BB8', + badgeBgHex: '#E0F4FB', + ), + ], + xLabels: ['06:00', '09:00', '12:00', '15:00', '18:00', '21:00'], + chartData: [ + [0.1, 0.2, 0.5, 0.1, 0.05, 0.03, 0.02], + [0.05, 0.15, 0.60, 0.15, 0.03, 0.01, 0.01], + [0.2, 0.4, 0.25, 0.1, 0.03, 0.01, 0.01], + [0.35, 0.45, 0.15, 0.03, 0.01, 0.00, 0.01], + [0.25, 0.35, 0.30, 0.08, 0.01, 0.00, 0.01], + [0.30, 0.40, 0.20, 0.08, 0.01, 0.00, 0.01], + ], + ), + 'week': const MonitoringDataModel( + periodKey: 'week', + periodLabel: 'Minggu Ini', + summary: 'Grafik mingguan menunjukkan penurunan tingkat kecemasan sebesar 15% dibandingkan minggu lalu.', + emotionalCenter: EmotionalCenterModel( + status: 'Baik & Stabil', + level: 4, + description: 'Tingkat kesadaran emosionalmu meningkat signifikan minggu ini.', + textColorHex: '#2E7D32', + ), + risks: [ + RiskIndicatorModel( + name: 'Risiko Stres', + type: 'stress', + percent: 0.45, + levelLabel: 'Sedang (45%)', + colorHex: '#FB8C00', + badgeBgHex: '#FFF3E0', + ), + RiskIndicatorModel( + name: 'Risiko Anxiety', + type: 'anxiety', + percent: 0.35, + levelLabel: 'Rendah-Sedang (35%)', + colorHex: '#489BB8', + badgeBgHex: '#E0F4FB', + ), + RiskIndicatorModel( + name: 'Risiko Depresi', + type: 'depresi', + percent: 0.20, + levelLabel: 'Rendah (20%)', + colorHex: '#4CAF50', + badgeBgHex: '#E8F5E9', + ), + ], + xLabels: ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'], + chartData: [ + [0.3, 0.3, 0.2, 0.1, 0.05, 0.03, 0.02], + [0.25, 0.35, 0.25, 0.1, 0.03, 0.01, 0.01], + [0.2, 0.4, 0.3, 0.05, 0.03, 0.01, 0.01], + [0.4, 0.4, 0.15, 0.03, 0.01, 0.00, 0.01], + [0.5, 0.3, 0.15, 0.03, 0.01, 0.00, 0.01], + [0.6, 0.3, 0.08, 0.01, 0.00, 0.00, 0.01], + [0.55, 0.35, 0.08, 0.01, 0.00, 0.00, 0.01], + ], + ), + }; + + @override + Future getMonitoringData(String periodKey) async { + await Future.delayed(const Duration(milliseconds: 300)); + return _mockData[periodKey] ?? _mockData['today']!; + } +} diff --git a/apps/eva_mobile/lib/features/monitoring/data/datasources/monitoring_datasource.dart b/apps/eva_mobile/lib/features/monitoring/data/datasources/monitoring_datasource.dart new file mode 100644 index 0000000..21f4bcc --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/data/datasources/monitoring_datasource.dart @@ -0,0 +1,5 @@ +import '../models/monitoring_data_model.dart'; + +abstract class MonitoringDataSource { + Future getMonitoringData(String periodKey); +} diff --git a/apps/eva_mobile/lib/features/monitoring/data/datasources/remote_monitoring_datasource.dart b/apps/eva_mobile/lib/features/monitoring/data/datasources/remote_monitoring_datasource.dart new file mode 100644 index 0000000..b47684d --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/data/datasources/remote_monitoring_datasource.dart @@ -0,0 +1,15 @@ +import 'monitoring_datasource.dart'; +import '../models/monitoring_data_model.dart'; +import '../../../../core/network/api_client.dart'; + +class RemoteMonitoringDataSource implements MonitoringDataSource { + final ApiClient _apiClient; + + RemoteMonitoringDataSource({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + @override + Future getMonitoringData(String periodKey) async { + final response = await _apiClient.get('/analytics/monitoring', queryParams: {'period': periodKey}); + return MonitoringDataModel.fromJson(response); + } +} diff --git a/apps/eva_mobile/lib/features/monitoring/data/models/monitoring_data_model.dart b/apps/eva_mobile/lib/features/monitoring/data/models/monitoring_data_model.dart new file mode 100644 index 0000000..89c945b --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/data/models/monitoring_data_model.dart @@ -0,0 +1,109 @@ +import '../../domain/entities/monitoring_data_entity.dart'; + +class RiskIndicatorModel extends RiskIndicatorEntity { + const RiskIndicatorModel({ + required super.name, + required super.type, + required super.percent, + required super.levelLabel, + required super.colorHex, + required super.badgeBgHex, + }); + + factory RiskIndicatorModel.fromJson(Map json) { + return RiskIndicatorModel( + name: json['name']?.toString() ?? '', + type: json['type']?.toString() ?? '', + percent: (json['percent'] as num?)?.toDouble() ?? 0.0, + levelLabel: json['levelLabel']?.toString() ?? json['level_label']?.toString() ?? '', + colorHex: json['colorHex']?.toString() ?? json['color_hex']?.toString() ?? '#D32F2F', + badgeBgHex: json['badgeBgHex']?.toString() ?? json['badge_bg_hex']?.toString() ?? '#FFDCDD', + ); + } + + Map toJson() { + return { + 'name': name, + 'type': type, + 'percent': percent, + 'levelLabel': levelLabel, + 'colorHex': colorHex, + 'badgeBgHex': badgeBgHex, + }; + } +} + +class EmotionalCenterModel extends EmotionalCenterEntity { + const EmotionalCenterModel({ + required super.status, + required super.level, + required super.description, + required super.textColorHex, + }); + + factory EmotionalCenterModel.fromJson(Map json) { + return EmotionalCenterModel( + status: json['status']?.toString() ?? '', + level: (json['level'] as num?)?.toInt() ?? 3, + description: json['description']?.toString() ?? '', + textColorHex: json['textColorHex']?.toString() ?? json['text_color_hex']?.toString() ?? '#F57F17', + ); + } + + Map toJson() { + return { + 'status': status, + 'level': level, + 'description': description, + 'textColorHex': textColorHex, + }; + } +} + +class MonitoringDataModel extends MonitoringDataEntity { + const MonitoringDataModel({ + required super.periodKey, + required super.periodLabel, + required super.summary, + required super.emotionalCenter, + required super.risks, + required super.xLabels, + required super.chartData, + }); + + factory MonitoringDataModel.fromJson(Map json) { + final rawRisks = json['risks'] as List? ?? []; + final rawXLabels = (json['xLabels'] ?? json['x_labels']) as List? ?? []; + final rawChartData = (json['chartData'] ?? json['chart_data']) as List? ?? []; + + final rawCenter = json['emotionalCenter'] ?? json['emotional_center']; + final centerMap = rawCenter is Map ? rawCenter : const {}; + + return MonitoringDataModel( + periodKey: json['periodKey']?.toString() ?? json['period_key']?.toString() ?? 'today', + periodLabel: json['periodLabel']?.toString() ?? json['period_label']?.toString() ?? 'Hari Ini', + summary: json['summary']?.toString() ?? '', + emotionalCenter: EmotionalCenterModel.fromJson(centerMap), + risks: rawRisks + .whereType() + .map((r) => RiskIndicatorModel.fromJson(Map.from(r))) + .toList(), + xLabels: rawXLabels.map((x) => x.toString()).toList(), + chartData: rawChartData + .map((row) => (row as List? ?? []).map((val) => (val as num).toDouble()).toList()) + .toList(), + ); + } + + Map toJson() { + return { + 'periodKey': periodKey, + 'periodLabel': periodLabel, + 'summary': summary, + 'emotionalCenter': (emotionalCenter as EmotionalCenterModel).toJson(), + 'risks': risks.map((r) => (r as RiskIndicatorModel).toJson()).toList(), + 'xLabels': xLabels, + 'chartData': chartData, + }; + } +} diff --git a/apps/eva_mobile/lib/features/monitoring/data/repositories/monitoring_repository_impl.dart b/apps/eva_mobile/lib/features/monitoring/data/repositories/monitoring_repository_impl.dart new file mode 100644 index 0000000..494d438 --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/data/repositories/monitoring_repository_impl.dart @@ -0,0 +1,12 @@ +import '../../domain/repositories/monitoring_repository.dart'; +import '../../domain/entities/monitoring_data_entity.dart'; +import '../datasources/monitoring_datasource.dart'; + +class MonitoringRepositoryImpl implements MonitoringRepository { + final MonitoringDataSource _dataSource; + + MonitoringRepositoryImpl(this._dataSource); + + @override + Future getMonitoringData(String periodKey) => _dataSource.getMonitoringData(periodKey); +} diff --git a/apps/eva_mobile/lib/features/monitoring/domain/entities/monitoring_data_entity.dart b/apps/eva_mobile/lib/features/monitoring/domain/entities/monitoring_data_entity.dart new file mode 100644 index 0000000..dfd166e --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/domain/entities/monitoring_data_entity.dart @@ -0,0 +1,51 @@ +class RiskIndicatorEntity { + final String name; + final String type; + final double percent; + final String levelLabel; + final String colorHex; + final String badgeBgHex; + + const RiskIndicatorEntity({ + required this.name, + required this.type, + required this.percent, + required this.levelLabel, + required this.colorHex, + required this.badgeBgHex, + }); +} + +class EmotionalCenterEntity { + final String status; + final int level; + final String description; + final String textColorHex; + + const EmotionalCenterEntity({ + required this.status, + required this.level, + required this.description, + required this.textColorHex, + }); +} + +class MonitoringDataEntity { + final String periodKey; // 'today', 'week', 'month' + final String periodLabel; + final String summary; + final EmotionalCenterEntity emotionalCenter; + final List risks; + final List xLabels; + final List> chartData; + + const MonitoringDataEntity({ + required this.periodKey, + required this.periodLabel, + required this.summary, + required this.emotionalCenter, + required this.risks, + required this.xLabels, + required this.chartData, + }); +} diff --git a/apps/eva_mobile/lib/features/monitoring/domain/repositories/monitoring_repository.dart b/apps/eva_mobile/lib/features/monitoring/domain/repositories/monitoring_repository.dart new file mode 100644 index 0000000..0467043 --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/domain/repositories/monitoring_repository.dart @@ -0,0 +1,5 @@ +import '../entities/monitoring_data_entity.dart'; + +abstract class MonitoringRepository { + Future getMonitoringData(String 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 new file mode 100644 index 0000000..2c8cefd --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/presentation/providers/monitoring_provider.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/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'; + +final monitoringRepositoryProvider = Provider((ref) { + if (AppConfig.useMockData) { + return MonitoringRepositoryImpl(MockMonitoringDataSource()); + } else { + return MonitoringRepositoryImpl(RemoteMonitoringDataSource()); + } +}); + +final selectedPeriodProvider = StateProvider((ref) => 'today'); + +final monitoringDataProvider = FutureProvider((ref) async { + final repo = ref.watch(monitoringRepositoryProvider); + final period = ref.watch(selectedPeriodProvider); + return repo.getMonitoringData(period); +}); 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 new file mode 100644 index 0000000..20b6c3f --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/data/datasources/mock_recommendation_datasource.dart @@ -0,0 +1,54 @@ +import 'recommendation_datasource.dart'; +import '../models/recommendation_model.dart'; + +class MockRecommendationDataSource implements RecommendationDataSource { + final List _items = [ + const RecommendationModel( + id: 'rec_1', + title: 'Latihan Pernapasan', + subtitle: 'Luangkan waktu sejenak untuk menenangkan diri. (3 menit)', + category: 'breathing', + iconName: 'air', + iconBgHex: '#8B93FF', + ), + const RecommendationModel( + id: 'rec_2', + title: 'Pertanyaan Refleksi Harian', + subtitle: 'Apa satu hal yang memberimu rasa nyaman hari ini?', + category: 'reflection', + iconName: 'edit_note', + iconBgHex: '#C3B8FF', + ), + const RecommendationModel( + id: 'rec_3', + title: 'Perawatan Diri', + subtitle: 'Jalan santai selama 10 menit.', + category: 'self_care', + iconName: 'directions_walk', + iconBgHex: '#489BB8', + ), + const RecommendationModel( + id: 'rec_4', + title: 'Latihan Reframing Kognitif', + subtitle: 'Melihat masalah dari sudut pandang yang lebih positif.', + category: 'cognitive_reframing', + iconName: 'psychology', + iconBgHex: '#9EA3C0', + ), + ]; + + @override + Future> getRecommendations() async { + await Future.delayed(const Duration(milliseconds: 250)); + return List.unmodifiable(_items); + } + + @override + Future markAsCompleted(String id) async { + await Future.delayed(const Duration(milliseconds: 200)); + final idx = _items.indexWhere((e) => e.id == id); + if (idx != -1) { + _items[idx] = _items[idx].copyWith(isCompleted: true); + } + } +} 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 new file mode 100644 index 0000000..8aad18a --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/data/datasources/recommendation_datasource.dart @@ -0,0 +1,6 @@ +import '../models/recommendation_model.dart'; + +abstract class RecommendationDataSource { + Future> getRecommendations(); + Future 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 new file mode 100644 index 0000000..9d1506c --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/data/datasources/remote_recommendation_datasource.dart @@ -0,0 +1,21 @@ +import 'recommendation_datasource.dart'; +import '../models/recommendation_model.dart'; +import '../../../../core/network/api_client.dart'; + +class RemoteRecommendationDataSource implements RecommendationDataSource { + final ApiClient _apiClient; + + RemoteRecommendationDataSource({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + @override + Future> getRecommendations() async { + final response = await _apiClient.get('/recommendations'); + final list = response as List? ?? []; + return list.map((e) => RecommendationModel.fromJson(e)).toList(); + } + + @override + Future markAsCompleted(String id) async { + await _apiClient.post('/recommendations/$id/complete'); + } +} diff --git a/apps/eva_mobile/lib/features/recommendations/data/models/recommendation_model.dart b/apps/eva_mobile/lib/features/recommendations/data/models/recommendation_model.dart new file mode 100644 index 0000000..c02cee8 --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/data/models/recommendation_model.dart @@ -0,0 +1,57 @@ +import '../../domain/entities/recommendation_entity.dart'; + +class RecommendationModel extends RecommendationEntity { + const RecommendationModel({ + required super.id, + required super.title, + required super.subtitle, + required super.category, + required super.iconName, + required super.iconBgHex, + super.isCompleted = false, + }); + + factory RecommendationModel.fromJson(Map json) { + return RecommendationModel( + id: json['id']?.toString() ?? '', + title: json['title']?.toString() ?? '', + subtitle: json['subtitle']?.toString() ?? '', + category: json['category']?.toString() ?? 'general', + iconName: json['icon_name']?.toString() ?? json['iconName']?.toString() ?? 'air', + iconBgHex: json['icon_bg_hex']?.toString() ?? json['iconBgHex']?.toString() ?? '#8B93FF', + isCompleted: json['is_completed'] ?? json['isCompleted'] ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'subtitle': subtitle, + 'category': category, + 'icon_name': iconName, + 'icon_bg_hex': iconBgHex, + 'is_completed': isCompleted, + }; + } + + RecommendationModel copyWith({ + String? id, + String? title, + String? subtitle, + String? category, + String? iconName, + String? iconBgHex, + bool? isCompleted, + }) { + return RecommendationModel( + id: id ?? this.id, + title: title ?? this.title, + subtitle: subtitle ?? this.subtitle, + category: category ?? this.category, + iconName: iconName ?? this.iconName, + iconBgHex: iconBgHex ?? this.iconBgHex, + isCompleted: isCompleted ?? this.isCompleted, + ); + } +} 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 new file mode 100644 index 0000000..842218a --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/data/repositories/recommendation_repository_impl.dart @@ -0,0 +1,15 @@ +import '../../domain/repositories/recommendation_repository.dart'; +import '../../domain/entities/recommendation_entity.dart'; +import '../datasources/recommendation_datasource.dart'; + +class RecommendationRepositoryImpl implements RecommendationRepository { + final RecommendationDataSource _dataSource; + + RecommendationRepositoryImpl(this._dataSource); + + @override + Future> getRecommendations() => _dataSource.getRecommendations(); + + @override + Future markAsCompleted(String id) => _dataSource.markAsCompleted(id); +} diff --git a/apps/eva_mobile/lib/features/recommendations/domain/entities/recommendation_entity.dart b/apps/eva_mobile/lib/features/recommendations/domain/entities/recommendation_entity.dart new file mode 100644 index 0000000..68b8a4e --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/domain/entities/recommendation_entity.dart @@ -0,0 +1,19 @@ +class RecommendationEntity { + final String id; + final String title; + final String subtitle; + final String category; + final String iconName; + final String iconBgHex; + final bool isCompleted; + + const RecommendationEntity({ + required this.id, + required this.title, + required this.subtitle, + required this.category, + required this.iconName, + required this.iconBgHex, + this.isCompleted = false, + }); +} 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 new file mode 100644 index 0000000..6904d2a --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/domain/repositories/recommendation_repository.dart @@ -0,0 +1,6 @@ +import '../entities/recommendation_entity.dart'; + +abstract class RecommendationRepository { + Future> getRecommendations(); + Future 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 new file mode 100644 index 0000000..5ff35d3 --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/presentation/providers/recommendation_provider.dart @@ -0,0 +1,20 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/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'; + +final recommendationRepositoryProvider = Provider((ref) { + if (AppConfig.useMockData) { + return RecommendationRepositoryImpl(MockRecommendationDataSource()); + } else { + return RecommendationRepositoryImpl(RemoteRecommendationDataSource()); + } +}); + +final recommendationsProvider = FutureProvider>((ref) async { + final repo = ref.watch(recommendationRepositoryProvider); + return repo.getRecommendations(); +}); diff --git a/apps/eva_mobile/lib/features/voice_call/config/call_config.dart b/apps/eva_mobile/lib/features/voice_call/config/call_config.dart new file mode 100644 index 0000000..065861d --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/config/call_config.dart @@ -0,0 +1,22 @@ +/// Centralized configuration for Hybrid Half-Duplex voice conversation timings and flags. +class CallConfig { + const CallConfig._(); + + // Guard delays in milliseconds + static const int sttRestartDelayMs = 200; + static const int playbackToListeningGuardMs = 200; + static const int bargeInGuardMs = 150; + static const int interChunkPauseMs = 500; // Jeda alami antar-kalimat saat AI berbicara + + // Adaptive turn baseline timeouts (ms) + static const int completeWaitMs = 650; + static const int normalWaitMs = 900; + static const int incompleteWaitMs = 1500; + static const int hesitationWaitMs = 1400; + static const int hardMaxInactivityMs = 2200; + + // Feature toggles + static const bool autoRestartStt = true; + static const bool manualBargeInEnabled = true; + static const bool hybridConversationEnabled = true; +} diff --git a/apps/eva_mobile/lib/features/voice_call/data/datasources/mock_voice_call_datasource.dart b/apps/eva_mobile/lib/features/voice_call/data/datasources/mock_voice_call_datasource.dart new file mode 100644 index 0000000..4806ffe --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/data/datasources/mock_voice_call_datasource.dart @@ -0,0 +1,41 @@ +import 'voice_call_datasource.dart'; +import '../models/voice_session_model.dart'; + +class MockVoiceCallDataSource implements VoiceCallDataSource { + VoiceSessionModel? _activeSession; + + @override + Future startCall() async { + await Future.delayed(const Duration(milliseconds: 300)); + _activeSession = VoiceSessionModel( + sessionId: 'call_${DateTime.now().millisecondsSinceEpoch}', + status: 'active', + durationSeconds: 0, + ); + return _activeSession!; + } + + @override + Future endCall(String sessionId, int durationSeconds) async { + await Future.delayed(const Duration(milliseconds: 400)); + final summaryText = 'Ringkasan Sesi Telepon (${(durationSeconds ~/ 60)}m ${durationSeconds % 60}s):\n' + 'â€ĸ Percakapan berlangsung hangat mengenai pengelolaan kecemasan.\n' + 'â€ĸ EVA memberikan panduan pernapasan dan saran istirahat singkat.\n' + 'â€ĸ Mood pengguna berangsur tenang.'; + + final ended = VoiceSessionModel( + sessionId: sessionId, + status: 'ended', + durationSeconds: durationSeconds, + summary: summaryText, + ); + _activeSession = ended; + return ended; + } + + @override + Future getSessionSummary(String sessionId) async { + await Future.delayed(const Duration(milliseconds: 200)); + return _activeSession; + } +} diff --git a/apps/eva_mobile/lib/features/voice_call/data/datasources/remote_voice_call_datasource.dart b/apps/eva_mobile/lib/features/voice_call/data/datasources/remote_voice_call_datasource.dart new file mode 100644 index 0000000..25786e6 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/data/datasources/remote_voice_call_datasource.dart @@ -0,0 +1,30 @@ +import 'voice_call_datasource.dart'; +import '../models/voice_session_model.dart'; +import '../../../../core/network/api_client.dart'; + +class RemoteVoiceCallDataSource implements VoiceCallDataSource { + final ApiClient _apiClient; + + RemoteVoiceCallDataSource({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + @override + Future startCall() async { + final response = await _apiClient.post('/voice/calls/start'); + return VoiceSessionModel.fromJson(response); + } + + @override + Future endCall(String sessionId, int durationSeconds) async { + final response = await _apiClient.post('/voice/calls/$sessionId/end', body: { + 'duration_seconds': durationSeconds, + }); + return VoiceSessionModel.fromJson(response); + } + + @override + Future getSessionSummary(String sessionId) async { + final response = await _apiClient.get('/voice/calls/$sessionId/summary'); + if (response == null) return null; + return VoiceSessionModel.fromJson(response); + } +} diff --git a/apps/eva_mobile/lib/features/voice_call/data/datasources/voice_call_datasource.dart b/apps/eva_mobile/lib/features/voice_call/data/datasources/voice_call_datasource.dart new file mode 100644 index 0000000..9897be3 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/data/datasources/voice_call_datasource.dart @@ -0,0 +1,7 @@ +import '../models/voice_session_model.dart'; + +abstract class VoiceCallDataSource { + Future startCall(); + Future endCall(String sessionId, int durationSeconds); + Future getSessionSummary(String sessionId); +} diff --git a/apps/eva_mobile/lib/features/voice_call/data/datasources/voice_call_ws_client.dart b/apps/eva_mobile/lib/features/voice_call/data/datasources/voice_call_ws_client.dart new file mode 100644 index 0000000..bf30105 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/data/datasources/voice_call_ws_client.dart @@ -0,0 +1,231 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import '../../../../config/app_config.dart'; + +/// WebSocket client for Hybrid Half-Duplex AI Voice Call protocol. +class VoiceCallWsClient { + WebSocketChannel? _channel; + StreamSubscription? _subscription; + bool _isConnected = false; + + final StreamController> _eventController = + StreamController>.broadcast(); + final StreamController _audioController = + StreamController.broadcast(); + + Stream> get eventStream => _eventController.stream; + Stream get audioStream => _audioController.stream; + bool get isConnected => _isConnected; + + Future connect(String sessionId) async { + disconnect(); + + final wsBase = AppConfig.wsUrl.endsWith('/') + ? AppConfig.wsUrl.substring(0, AppConfig.wsUrl.length - 1) + : AppConfig.wsUrl; + final token = await AppConfig.getToken(); + final uriStr = (token != null && token.isNotEmpty) + ? '$wsBase/call/ws/$sessionId?token=$token' + : '$wsBase/call/ws/$sessionId'; + final uri = Uri.parse(uriStr); + debugPrint('🔌 [WS CLIENT] Connecting: $uri'); + + try { + _channel = WebSocketChannel.connect(uri); + _isConnected = true; + + _subscription = _channel!.stream.listen( + (data) { + if (data is Uint8List) { + if (!_audioController.isClosed) _audioController.add(data); + } else if (data is List) { + if (!_audioController.isClosed) { + _audioController.add(Uint8List.fromList(data)); + } + } else if (data is String) { + try { + final jsonMap = jsonDecode(data) as Map; + debugPrint('📩 [WS CLIENT <- EVENT] ${jsonMap['type']}'); + + if (!_eventController.isClosed) { + _eventController.add(jsonMap); + } + } catch (e) { + debugPrint('âš ī¸ [WS CLIENT JSON DECODE ERROR]: $e'); + } + } + }, + onError: (error) { + debugPrint('❌ [WS CLIENT ERROR]: $error'); + _isConnected = false; + if (!_eventController.isClosed) { + _eventController.add({ + 'type': 'error', + 'message': error.toString(), + }); + } + }, + onDone: () { + debugPrint('🔌 [WS CLIENT CLOSED]'); + _isConnected = false; + if (!_eventController.isClosed) { + _eventController.add({'type': 'disconnected'}); + } + }, + ); + } catch (e) { + debugPrint('❌ [WS CLIENT EXCEPTION]: $e'); + _isConnected = false; + if (!_eventController.isClosed) { + _eventController.add({ + 'type': 'error', + 'message': e.toString(), + }); + } + } + } + + void _sendJson(Map payload) { + if (_channel != null && _isConnected) { + try { + _channel!.sink.add(jsonEncode(payload)); + } catch (e) { + debugPrint('âš ī¸ [WS CLIENT SEND ERROR]: $e'); + } + } + } + + void sendStartCall({String? voiceMode}) { + final payload = {'type': 'start_call'}; + if (voiceMode != null && voiceMode.isNotEmpty) { + payload['voice_mode'] = voiceMode; + } + _sendJson(payload); + } + + void sendPartial({ + required String callId, + required int userTurnId, + required int sttSessionId, + required int sequence, + required String text, + }) { + _sendJson({ + 'type': 'stt.partial', + 'call_id': callId, + 'user_turn_id': userTurnId, + 'stt_session_id': sttSessionId, + 'sequence': sequence, + 'text': text, + }); + } + + void sendFinalSegment({ + required String callId, + required int userTurnId, + required int sttSessionId, + required int sequence, + required String text, + }) { + _sendJson({ + 'type': 'stt.final_segment', + 'call_id': callId, + 'user_turn_id': userTurnId, + 'stt_session_id': sttSessionId, + 'sequence': sequence, + 'text': text, + }); + } + + void sendStatus({ + required String callId, + required int userTurnId, + required int sttSessionId, + required String status, + }) { + _sendJson({ + 'type': 'stt.status', + 'status': status, + 'call_id': callId, + 'user_turn_id': userTurnId, + 'stt_session_id': sttSessionId, + }); + } + + void sendInterrupt({ + required String callId, + required int assistantTurnId, + String reason = 'user_barge_in', + }) { + _sendJson({ + 'type': 'assistant.interrupt', + 'call_id': callId, + 'assistant_turn_id': assistantTurnId, + 'reason': reason, + }); + // Legacy interrupt for backward compatibility + _sendJson({'type': 'user_interrupted'}); + } + + void sendPlaybackFinished({ + required String callId, + required int assistantTurnId, + }) { + _sendJson({ + 'type': 'playback.finished', + 'call_id': callId, + 'assistant_turn_id': assistantTurnId, + }); + } + + void sendForceCommit({ + required String callId, + required int userTurnId, + }) { + _sendJson({ + 'type': 'user.force_commit', + 'call_id': callId, + 'user_turn_id': userTurnId, + }); + } + + void sendSync({ + required String callId, + required String knownState, + required int userTurnId, + required int assistantTurnId, + }) { + _sendJson({ + 'type': 'call.sync', + 'call_id': callId, + 'known_state': knownState, + 'user_turn_id': userTurnId, + 'assistant_turn_id': assistantTurnId, + }); + } + + void sendEndCall(int durationSeconds) { + _sendJson({ + 'type': 'end_call', + 'duration_seconds': durationSeconds, + }); + } + + void disconnect() { + _subscription?.cancel(); + _subscription = null; + try { + _channel?.sink.close(); + } catch (_) {} + _channel = null; + _isConnected = false; + } + + void dispose() { + disconnect(); + _eventController.close(); + _audioController.close(); + } +} diff --git a/apps/eva_mobile/lib/features/voice_call/data/models/voice_session_model.dart b/apps/eva_mobile/lib/features/voice_call/data/models/voice_session_model.dart new file mode 100644 index 0000000..57d2a12 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/data/models/voice_session_model.dart @@ -0,0 +1,42 @@ +import '../../domain/entities/voice_session_entity.dart'; + +class VoiceSessionModel extends VoiceSessionEntity { + const VoiceSessionModel({ + required super.sessionId, + required super.status, + required super.durationSeconds, + super.summary, + }); + + factory VoiceSessionModel.fromJson(Map json) { + return VoiceSessionModel( + sessionId: json['session_id']?.toString() ?? json['sessionId']?.toString() ?? '', + status: json['status']?.toString() ?? 'active', + durationSeconds: json['duration_seconds'] ?? json['durationSeconds'] ?? 0, + summary: json['summary']?.toString(), + ); + } + + Map toJson() { + return { + 'session_id': sessionId, + 'status': status, + 'duration_seconds': durationSeconds, + 'summary': summary, + }; + } + + VoiceSessionModel copyWith({ + String? sessionId, + String? status, + int? durationSeconds, + String? summary, + }) { + return VoiceSessionModel( + sessionId: sessionId ?? this.sessionId, + status: status ?? this.status, + durationSeconds: durationSeconds ?? this.durationSeconds, + summary: summary ?? this.summary, + ); + } +} diff --git a/apps/eva_mobile/lib/features/voice_call/data/repositories/voice_call_repository_impl.dart b/apps/eva_mobile/lib/features/voice_call/data/repositories/voice_call_repository_impl.dart new file mode 100644 index 0000000..2563557 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/data/repositories/voice_call_repository_impl.dart @@ -0,0 +1,18 @@ +import '../../domain/repositories/voice_call_repository.dart'; +import '../../domain/entities/voice_session_entity.dart'; +import '../datasources/voice_call_datasource.dart'; + +class VoiceCallRepositoryImpl implements VoiceCallRepository { + final VoiceCallDataSource _dataSource; + + VoiceCallRepositoryImpl(this._dataSource); + + @override + Future startCall() => _dataSource.startCall(); + + @override + Future endCall(String sessionId, int durationSeconds) => _dataSource.endCall(sessionId, durationSeconds); + + @override + Future getSessionSummary(String sessionId) => _dataSource.getSessionSummary(sessionId); +} diff --git a/apps/eva_mobile/lib/features/voice_call/data/services/ai_audio_playback_service.dart b/apps/eva_mobile/lib/features/voice_call/data/services/ai_audio_playback_service.dart new file mode 100644 index 0000000..ac9e0be --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/data/services/ai_audio_playback_service.dart @@ -0,0 +1,298 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:math' as math; +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; + +import '../../config/call_config.dart'; + +class QueuedAudioChunk { + final Uint8List bytes; + final int assistantTurnId; + final int sequence; + final List envelope; + + const QueuedAudioChunk({ + required this.bytes, + required this.assistantTurnId, + required this.sequence, + this.envelope = const [], + }); +} + +/// Service managing sequential audio chunk queueing, playback, and instant cancellation. +class AiAudioPlaybackService { + final AudioPlayer _player; + + AiAudioPlaybackService({AudioPlayer? player}) : _player = player ?? AudioPlayer() { + _initPlayerListeners(); + } + + final Queue _queue = Queue(); + final Set _cancelledTurnIds = {}; + + int _currentAssistantTurnId = 0; + bool _isPlaying = false; + bool _isStreamFinished = false; + DateTime? _lastChunkCompletedAt; + + final StreamController _playbackCompletedController = + StreamController.broadcast(); + final StreamController _soundLevelController = + StreamController.broadcast(); + + Stream get onPlaybackCompleted => _playbackCompletedController.stream; + Stream get soundLevelStream => _soundLevelController.stream; + bool get isPlaying => _isPlaying; + int get currentAssistantTurnId => _currentAssistantTurnId; + + Timer? _envelopeTimer; + final Stopwatch _playbackStopwatch = Stopwatch(); + + void _initPlayerListeners() { + _player.onPlayerComplete.listen((_) async { + _stopEnvelopeTicker(); + _isPlaying = false; + _lastChunkCompletedAt = DateTime.now(); + + // Tunggu jeda alami antar-kalimat sebelum memainkan chunk berikutnya + if (_queue.isNotEmpty) { + await Future.delayed(const Duration(milliseconds: CallConfig.interChunkPauseMs)); + } + + if (!_cancelledTurnIds.contains(_currentAssistantTurnId)) { + _playNextChunk(); + } + }); + } + + /// Start a new assistant turn playback context + void prepareNewAssistantTurn(int assistantTurnId) { + if (_currentAssistantTurnId != assistantTurnId) { + stop(); + _currentAssistantTurnId = assistantTurnId; + _isStreamFinished = false; + _lastChunkCompletedAt = null; + } + } + + /// Enqueue an incoming TTS audio byte chunk with its time-aligned speech envelope + void enqueueChunk({ + required Uint8List bytes, + required int assistantTurnId, + required int sequence, + List envelope = const [], + }) { + // Drop if turn has been cancelled or belongs to older assistant turn + if (_cancelledTurnIds.contains(assistantTurnId) || assistantTurnId < _currentAssistantTurnId) { + debugPrint('⚡ [AUDIO PLAYBACK DISCARDED]: Dropping chunk for cancelled/stale turn $assistantTurnId'); + return; + } + + _currentAssistantTurnId = assistantTurnId; + _queue.add(QueuedAudioChunk( + bytes: bytes, + assistantTurnId: assistantTurnId, + sequence: sequence, + envelope: envelope, + )); + + if (!_isPlaying) { + _playNextChunk(); + } + } + + /// Mark that backend finished streaming all TTS chunks for this assistant turn + void markStreamFinished(int assistantTurnId) { + if (assistantTurnId == _currentAssistantTurnId) { + _isStreamFinished = true; + if (!_isPlaying && _queue.isEmpty) { + _notifyCompleted(); + } + } + } + + Future _playNextChunk() async { + if (_queue.isEmpty) { + _isPlaying = false; + if (_isStreamFinished) { + _notifyCompleted(); + } + return; + } + + final nextChunk = _queue.removeFirst(); + + // Check cancellation once more before playing + if (_cancelledTurnIds.contains(nextChunk.assistantTurnId)) { + _playNextChunk(); + return; + } + + // Pastikan jeda minimal antar kalimat jika chunk baru tiba terlambat + if (_lastChunkCompletedAt != null) { + final elapsed = DateTime.now().difference(_lastChunkCompletedAt!).inMilliseconds; + if (elapsed < CallConfig.interChunkPauseMs) { + await Future.delayed(Duration(milliseconds: CallConfig.interChunkPauseMs - elapsed)); + if (_cancelledTurnIds.contains(nextChunk.assistantTurnId)) { + return; + } + } + } + + try { + _isPlaying = true; + debugPrint('🔊 [AUDIO PLAYING CHUNK]: Turn ${nextChunk.assistantTurnId}, Seq ${nextChunk.sequence} (${nextChunk.bytes.length} bytes, ${nextChunk.envelope.length} env points)'); + _playbackStopwatch.reset(); + _playbackStopwatch.start(); + _startEnvelopeTicker(nextChunk.envelope); + + await _player.play(BytesSource(nextChunk.bytes)); + } catch (e) { + debugPrint('âš ī¸ [AUDIO PLAY EXCEPTION]: $e'); + _stopEnvelopeTicker(); + _isPlaying = false; + _playNextChunk(); + } + } + + void _startEnvelopeTicker(List envelope) { + _envelopeTimer?.cancel(); + if (envelope.isEmpty) { + _envelopeTimer = Timer.periodic(const Duration(milliseconds: 30), (timer) { + if (!_isPlaying) { + _stopEnvelopeTicker(); + return; + } + final elapsedMs = _playbackStopwatch.elapsedMilliseconds; + final t = elapsedMs / 1000.0; + + // Realistic speech cadence generator: + // 1. Syllable pulse (3.8 Hz - 4.2 Hz, average Indonesian syllable pace: ~4 per second) + final double syllablePhase = (t * 4.0 * 2 * math.pi); + final double syllableRaw = (math.sin(syllablePhase) + 1.0) / 2.0; + final double syllablePulse = math.pow(syllableRaw, 1.8).toDouble(); + + // 2. Prosodic intonation sweep (0.7 Hz macro-envelope, simulating sentence rise and fall) + final double intonationMacro = 0.55 + 0.45 * math.sin(t * 0.7 * 2 * math.pi); + + // 3. Syllabic stress variation (micro-variation between accented & unaccented syllables) + final double stress = 0.75 + 0.25 * math.sin(t * 1.9 * 2 * math.pi); + + // 4. Harmonic richness and micro-articulation jitter + final double jitter = 0.08 * math.sin(t * 9.5 * 2 * math.pi); + + // Combined dynamic vocal amplitude envelope + final double dynamicLevel = ((syllablePulse * stress * intonationMacro) + jitter) + .clamp(0.08, 0.95); + + if (!_soundLevelController.isClosed) { + _soundLevelController.add(dynamicLevel); + } + }); + return; + } + + _envelopeTimer = Timer.periodic(const Duration(milliseconds: 30), (timer) { + if (!_isPlaying) { + _stopEnvelopeTicker(); + return; + } + final elapsedMs = _playbackStopwatch.elapsedMilliseconds; + // 50ms per envelope sample point + final double sampleIndex = elapsedMs / 50.0; + final int baseIdx = sampleIndex.floor(); + + if (baseIdx < envelope.length) { + final double frac = sampleIndex - baseIdx; + final double v1 = envelope[baseIdx]; + final double v2 = (baseIdx + 1 < envelope.length) ? envelope[baseIdx + 1] : 0.0; + final double level = v1 + (v2 - v1) * frac; + if (!_soundLevelController.isClosed) { + _soundLevelController.add(level.clamp(0.0, 1.0)); + } + } else { + if (!_soundLevelController.isClosed) { + _soundLevelController.add(0.0); + } + } + }); + } + + void _stopEnvelopeTicker() { + _envelopeTimer?.cancel(); + _envelopeTimer = null; + _playbackStopwatch.stop(); + if (!_soundLevelController.isClosed) { + _soundLevelController.add(0.0); + } + } + + void _notifyCompleted() { + final finishedTurn = _currentAssistantTurnId; + debugPrint('✅ [AUDIO PLAYBACK COMPLETED]: All audio for turn $finishedTurn finished playing.'); + if (!_playbackCompletedController.isClosed) { + _playbackCompletedController.add(finishedTurn); + } + } + + /// Immediately stop playback, flush audio queue, and invalidate current assistant turn + Future stop() async { + _stopEnvelopeTicker(); + if (_currentAssistantTurnId > 0) { + _cancelledTurnIds.add(_currentAssistantTurnId); + } + _queue.clear(); + _isPlaying = false; + _isStreamFinished = false; + _lastChunkCompletedAt = null; + + try { + await _player.stop(); + } catch (e) { + debugPrint('âš ī¸ [AUDIO STOP EXCEPTION]: $e'); + } + } + + /// Toggle or set speakerphone / earpiece routing + Future setSpeakerphoneOn(bool enabled) async { + try { + await AudioPlayer.global.setAudioContext( + AudioContext( + android: AudioContextAndroid( + isSpeakerphoneOn: enabled, + stayAwake: true, + contentType: AndroidContentType.speech, + usageType: AndroidUsageType.voiceCommunication, + audioFocus: AndroidAudioFocus.gainTransientMayDuck, + ), + iOS: AudioContextIOS( + category: AVAudioSessionCategory.playAndRecord, + options: enabled + ? const { + AVAudioSessionOptions.defaultToSpeaker, + AVAudioSessionOptions.allowBluetooth, + } + : const { + AVAudioSessionOptions.allowBluetooth, + }, + ), + ), + ); + debugPrint('🔊 [SPEAKERPHONE TOGGLED]: enabled = $enabled'); + } catch (e) { + debugPrint('âš ī¸ [SET SPEAKERPHONE ERROR]: $e'); + } + } + + void clearQueue() { + _queue.clear(); + } + + void dispose() { + stop(); + _player.dispose(); + _playbackCompletedController.close(); + _soundLevelController.close(); + } +} 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 new file mode 100644 index 0000000..1c66233 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/data/services/speech_recognition_service.dart @@ -0,0 +1,260 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:speech_to_text/speech_recognition_error.dart'; +import 'package:speech_to_text/speech_recognition_result.dart'; +import 'package:speech_to_text/speech_to_text.dart' as stt; + +class SttPartialResult { + final String text; + final int sttSessionId; + final int sequence; + + const SttPartialResult({ + required this.text, + required this.sttSessionId, + required this.sequence, + }); +} + +class SttFinalResult { + final String text; + final int sttSessionId; + final int sequence; + + const SttFinalResult({ + required this.text, + required this.sttSessionId, + required this.sequence, + }); +} + +class SttStatusEvent { + final String status; + final int sttSessionId; + + const SttStatusEvent({ + required this.status, + required this.sttSessionId, + }); +} + +/// Service wrapping Flutter speech_to_text with strict STT session tracking and sequence numbers. +class SpeechRecognitionService { + final stt.SpeechToText _speech; + + SpeechRecognitionService({stt.SpeechToText? speech}) + : _speech = speech ?? stt.SpeechToText(); + + bool _isInitialized = false; + int _activeSttSessionId = 0; + int _sequenceCounter = 0; + bool _isMuted = false; + + final StreamController _partialController = + StreamController.broadcast(); + final StreamController _finalSegmentController = + StreamController.broadcast(); + final StreamController _statusController = + StreamController.broadcast(); + final StreamController _soundLevelController = + StreamController.broadcast(); + final StreamController _errorController = + StreamController.broadcast(); + + Stream get partialStream => _partialController.stream; + Stream get finalSegmentStream => _finalSegmentController.stream; + Stream get statusStream => _statusController.stream; + Stream get soundLevelStream => _soundLevelController.stream; + Stream get errorStream => _errorController.stream; + + bool get isListening => _speech.isListening; + bool get isInitialized => _isInitialized; + int get activeSttSessionId => _activeSttSessionId; + bool get isMuted => _isMuted; + + String _resolvedLocaleId = 'id-ID'; + String get resolvedLocaleId => _resolvedLocaleId; + + Future initialize() async { + if (_isInitialized) return true; + try { + _isInitialized = await _speech.initialize( + onStatus: (status) { + debugPrint('đŸŽ™ī¸ [STT STATUS]: $status (active session: $_activeSttSessionId)'); + if (!_statusController.isClosed) { + _statusController.add(SttStatusEvent( + status: status, + sttSessionId: _activeSttSessionId, + )); + } + }, + onError: (SpeechRecognitionError error) { + debugPrint('âš ī¸ [STT ERROR]: ${error.errorMsg} (permanent: ${error.permanent})'); + // Filter out normal silence timeouts to avoid treating natural pauses as crashes + if (error.errorMsg == 'error_speech_timeout' || error.errorMsg == 'error_no_match') { + return; + } + if (!_errorController.isClosed) { + _errorController.add(error.errorMsg); + } + }, + ); + if (_isInitialized) { + try { + final locales = await _speech.locales(); + stt.LocaleName? matchedLocale; + for (final loc in locales) { + final normalized = loc.localeId.toLowerCase().replaceAll('_', '-'); + if (normalized == 'id-id' || normalized == 'id') { + matchedLocale = loc; + break; + } + } + if (matchedLocale != null) { + _resolvedLocaleId = matchedLocale.localeId; + debugPrint('🇮🇩 [STT LOCALE RESOLVED]: $_resolvedLocaleId (${matchedLocale.name})'); + } else { + _resolvedLocaleId = 'id-ID'; + debugPrint('🇮🇩 [STT LOCALE DEFAULT BCP-47]: $_resolvedLocaleId'); + } + } catch (locErr) { + debugPrint('âš ī¸ [STT LOCALE QUERY ERROR]: $locErr'); + _resolvedLocaleId = 'id-ID'; + } + } + debugPrint('đŸŽ™ī¸ [STT INITIALIZED]: $_isInitialized (Locale: $_resolvedLocaleId)'); + } catch (e) { + debugPrint('❌ [STT INIT EXCEPTION]: $e'); + _isInitialized = false; + } + return _isInitialized; + } + + void setMuted(bool muted) { + _isMuted = muted; + if (_isMuted && _speech.isListening) { + stopListening(); + } + } + + Future startListening({required int sttSessionId}) async { + if (_isMuted) return; + + if (!_isInitialized) { + final ok = await initialize(); + if (!ok) { + _errorController.add('Speech recognizer initialization failed or permission denied'); + return; + } + } + + _activeSttSessionId = sttSessionId; + _sequenceCounter = 0; + + if (_speech.isListening) { + try { + await _speech.stop(); + // Give native Android SpeechRecognizer audio hardware time to release + await Future.delayed(const Duration(milliseconds: 150)); + } catch (_) {} + } + + try { + await _speech.listen( + onResult: (SpeechRecognitionResult result) { + // Strict session token validation: Drop callbacks from outdated STT sessions + if (sttSessionId != _activeSttSessionId) { + debugPrint('âš ī¸ [STT DISCARDED]: Callback session $sttSessionId != active $_activeSttSessionId'); + return; + } + + final words = result.recognizedWords.trim(); + if (words.isEmpty) return; + + _sequenceCounter++; + + if (result.finalResult) { + debugPrint('đŸŽ™ī¸ [STT FINAL SEGMENT]: "$words" (session $sttSessionId, seq $_sequenceCounter)'); + if (!_finalSegmentController.isClosed) { + _finalSegmentController.add(SttFinalResult( + text: words, + sttSessionId: sttSessionId, + sequence: _sequenceCounter, + )); + } + } else { + debugPrint('đŸŽ™ī¸ [STT PARTIAL]: "$words" (session $sttSessionId, seq $_sequenceCounter)'); + if (!_partialController.isClosed) { + _partialController.add(SttPartialResult( + text: words, + sttSessionId: sttSessionId, + sequence: _sequenceCounter, + )); + } + } + }, + onSoundLevelChange: (double level) { + if (!_isMuted && !_soundLevelController.isClosed) { + // Normalize level between 0.0 and 1.0 for visual equalizer + final normalized = ((level + 10.0) / 30.0).clamp(0.05, 1.0); + _soundLevelController.add(normalized); + } + }, + localeId: _resolvedLocaleId, + listenFor: const Duration(seconds: 60), + pauseFor: const Duration(milliseconds: 2500), + listenOptions: stt.SpeechListenOptions( + localeId: _resolvedLocaleId, + listenMode: stt.ListenMode.dictation, + partialResults: true, + cancelOnError: false, + listenFor: const Duration(seconds: 60), + pauseFor: const Duration(milliseconds: 2500), + ), + ); + debugPrint('đŸŽ™ī¸ [STT STARTED]: Session $sttSessionId (Locale: $_resolvedLocaleId)'); + } catch (e) { + debugPrint('❌ [STT LISTEN EXCEPTION]: $e'); + _errorController.add('Listen failed: $e'); + } + } + + Future stopListening() async { + try { + if (_speech.isListening) { + await _speech.stop(); + } + } catch (e) { + debugPrint('âš ī¸ [STT STOP EXCEPTION]: $e'); + } + if (!_soundLevelController.isClosed) { + _soundLevelController.add(0.0); + } + } + + Future cancelListening() async { + try { + if (_speech.isListening) { + await _speech.cancel(); + } + } catch (e) { + debugPrint('âš ī¸ [STT CANCEL EXCEPTION]: $e'); + } + if (!_soundLevelController.isClosed) { + _soundLevelController.add(0.0); + } + } + + void dispose() { + try { + if (_speech.isListening) { + _speech.stop(); + } + } catch (_) {} + if (!_partialController.isClosed) _partialController.close(); + if (!_finalSegmentController.isClosed) _finalSegmentController.close(); + if (!_statusController.isClosed) _statusController.close(); + if (!_soundLevelController.isClosed) _soundLevelController.close(); + if (!_errorController.isClosed) _errorController.close(); + } +} diff --git a/apps/eva_mobile/lib/features/voice_call/domain/entities/call_state.dart b/apps/eva_mobile/lib/features/voice_call/domain/entities/call_state.dart new file mode 100644 index 0000000..fe22adf --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/domain/entities/call_state.dart @@ -0,0 +1,16 @@ +/// Central explicit conversation state machine for Hybrid Half-Duplex AI Call. +enum CallState { + idle, + listening, + thinking, + aiSpeaking, + interrupting, + ended, + error, +} + +/// Operating mode for voice interaction. +enum ConversationMode { + hybridAuto, + pushToTalk, +} diff --git a/apps/eva_mobile/lib/features/voice_call/domain/entities/voice_session_entity.dart b/apps/eva_mobile/lib/features/voice_call/domain/entities/voice_session_entity.dart new file mode 100644 index 0000000..e7a0ac4 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/domain/entities/voice_session_entity.dart @@ -0,0 +1,13 @@ +class VoiceSessionEntity { + final String sessionId; + final String status; + final int durationSeconds; + final String? summary; + + const VoiceSessionEntity({ + required this.sessionId, + required this.status, + required this.durationSeconds, + this.summary, + }); +} diff --git a/apps/eva_mobile/lib/features/voice_call/domain/repositories/voice_call_repository.dart b/apps/eva_mobile/lib/features/voice_call/domain/repositories/voice_call_repository.dart new file mode 100644 index 0000000..66cdc18 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/domain/repositories/voice_call_repository.dart @@ -0,0 +1,7 @@ +import '../entities/voice_session_entity.dart'; + +abstract class VoiceCallRepository { + Future startCall(); + Future endCall(String sessionId, int durationSeconds); + Future getSessionSummary(String sessionId); +} diff --git a/apps/eva_mobile/lib/features/voice_call/presentation/controllers/ai_call_controller.dart b/apps/eva_mobile/lib/features/voice_call/presentation/controllers/ai_call_controller.dart new file mode 100644 index 0000000..9e9460d --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/presentation/controllers/ai_call_controller.dart @@ -0,0 +1,538 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../config/call_config.dart'; +import '../../data/datasources/voice_call_ws_client.dart'; +import '../../data/services/ai_audio_playback_service.dart'; +import '../../data/services/speech_recognition_service.dart'; +import '../../domain/entities/call_state.dart'; +import 'ai_call_state.dart'; + +class AiCallController extends StateNotifier { + final SpeechRecognitionService _speechService; + final AiAudioPlaybackService _audioPlaybackService; + final VoiceCallWsClient _wsClient; + + AiCallController({ + required SpeechRecognitionService speechService, + required AiAudioPlaybackService audioPlaybackService, + required VoiceCallWsClient wsClient, + }) : _speechService = speechService, + _audioPlaybackService = audioPlaybackService, + _wsClient = wsClient, + super(const AiCallViewState()); + + StreamSubscription? _partialSubscription; + StreamSubscription? _finalSegmentSubscription; + StreamSubscription? _statusSubscription; + StreamSubscription? _soundLevelSubscription; + StreamSubscription? _aiAudioSoundSubscription; + StreamSubscription? _playbackSubscription; + StreamSubscription? _wsEventSubscription; + StreamSubscription? _wsAudioSubscription; + StreamSubscription? _errorSubscription; + Timer? _durationTimer; + Timer? _restartTimer; + Timer? _thinkingWatchdogTimer; + bool _isDisposed = false; + int _restartAttempts = 0; + + Future startCall({String? customCallId, String? voiceMode}) async { + final callId = customCallId ?? 'call_${DateTime.now().millisecondsSinceEpoch}'; + debugPrint('🚀 [AI CALL CONTROLLER] Starting call: $callId'); + + // Load active voice character mode from SharedPreferences if not specified + String effectiveVoiceMode = voiceMode ?? 'mode_2'; + if (voiceMode == null) { + try { + final prefs = await SharedPreferences.getInstance(); + effectiveVoiceMode = prefs.getString('settings_voice_mode') ?? 'mode_2'; + } catch (_) {} + } + + state = state.copyWith( + callId: callId, + voiceMode: effectiveVoiceMode, + callState: CallState.idle, + userTurnId: 1, + assistantTurnId: 0, + activeSttSessionId: 0, + currentTranscript: '', + aiTranscript: '', + callDurationSeconds: 0, + clearError: true, + clearCrisis: true, + ); + + _initSubscriptions(); + + // 1. Initialize Speech Recognizer + await _speechService.initialize(); + + // 2. Connect WebSocket + await _wsClient.connect(callId); + _wsClient.sendStartCall(voiceMode: effectiveVoiceMode); + + // 3. Start Duration Timer + _durationTimer?.cancel(); + _durationTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!_isDisposed && state.callState != CallState.ended) { + state = state.copyWith(callDurationSeconds: state.callDurationSeconds + 1); + } + }); + + // 4. Initial transition to LISTENING + await enterListening(); + } + + void _initSubscriptions() { + _cancelSubscriptions(); + + // STT Partial transcript + _partialSubscription = _speechService.partialStream.listen((partial) { + if (state.callState != CallState.listening) return; + if (partial.sttSessionId != state.activeSttSessionId) return; + + state = state.copyWith( + latestPartial: partial.text, + isUserSpeaking: true, + ); + + _wsClient.sendPartial( + callId: state.callId, + userTurnId: state.userTurnId, + sttSessionId: partial.sttSessionId, + sequence: partial.sequence, + text: partial.text, + ); + }); + + // STT Final Segment + _finalSegmentSubscription = _speechService.finalSegmentStream.listen((seg) { + if (state.callState != CallState.listening) return; + if (seg.sttSessionId != state.activeSttSessionId) return; + + state = state.copyWith( + currentTranscript: seg.text, + latestPartial: '', + isUserSpeaking: true, + ); + + _wsClient.sendFinalSegment( + callId: state.callId, + userTurnId: state.userTurnId, + sttSessionId: seg.sttSessionId, + sequence: seg.sequence, + text: seg.text, + ); + }); + + // STT Status + _statusSubscription = _speechService.statusStream.listen((statusEvent) { + if (statusEvent.sttSessionId != state.activeSttSessionId) return; + + _wsClient.sendStatus( + callId: state.callId, + userTurnId: state.userTurnId, + sttSessionId: statusEvent.sttSessionId, + status: statusEvent.status, + ); + }); + + // Sound Level + _soundLevelSubscription = _speechService.soundLevelStream.listen((level) { + if (!_isDisposed) { + state = state.copyWith(soundLevel: level); + } + }); + + // Error stream + _errorSubscription = _speechService.errorStream.listen((err) { + debugPrint('âš ī¸ [STT SERVICE ERROR]: $err'); + // If listening unexpectedly stopped and recoverable, notify status + if (state.callState == CallState.listening) { + _wsClient.sendStatus( + callId: state.callId, + userTurnId: state.userTurnId, + sttSessionId: state.activeSttSessionId, + status: 'error_$err', + ); + } + }); + + // Playback Completed (Queue completely empty) + _playbackSubscription = _audioPlaybackService.onPlaybackCompleted.listen((turnId) { + handlePlaybackFinished(turnId); + }); + + // AI Audio Playback Sound Level (real-time envelope intonation) + _aiAudioSoundSubscription = _audioPlaybackService.soundLevelStream.listen((level) { + if (state.callState == CallState.aiSpeaking) { + state = state.copyWith(soundLevel: level); + } + }); + + // WebSocket Incoming Audio Chunks + _wsAudioSubscription = _wsClient.audioStream.listen((audioBytes) { + if (state.callState != CallState.aiSpeaking) { + enterAiSpeaking(state.assistantTurnId); + } + _audioPlaybackService.enqueueChunk( + bytes: audioBytes, + assistantTurnId: state.assistantTurnId, + sequence: 0, + ); + }); + + // WebSocket Incoming Events + _wsEventSubscription = _wsClient.eventStream.listen((event) { + handleSocketEvent(event); + }); + } + + void handleSocketEvent(Map event) { + final type = event['type'] as String?; + if (type == null) return; + + debugPrint('📩 [AI CALL CONTROLLER <- WS] $type'); + + if (type == 'call_started') { + final confirmedMode = event['voice_mode'] as String?; + if (confirmedMode != null && confirmedMode.isNotEmpty) { + state = state.copyWith(voiceMode: confirmedMode); + } + } else if (type == 'turn.keep_open') { + final restart = event['restart_stt'] as bool? ?? false; + final waitMs = (event['wait_ms'] as num?)?.toInt() ?? CallConfig.sttRestartDelayMs; + // If we were waiting in thinking (e.g. from empty force commit), recover to listening immediately + if (state.callState == CallState.thinking) { + enterListening(); + } else { + handleKeepOpen(restart, waitMs); + } + } else if (type == 'turn.committed') { + final finalTranscript = event['final_transcript'] as String? ?? ''; + state = state.copyWith(currentTranscript: finalTranscript); + enterThinking(); + } else if (type == 'ai.thinking' || type == 'ai_thinking') { + final turnId = (event['assistant_turn_id'] as num?)?.toInt(); + if (turnId != null) { + state = state.copyWith(assistantTurnId: turnId); + } + enterThinking(); + } else if (type == 'ai.transcript_chunk' || type == 'ai_transcript_chunk') { + final text = event['text'] as String? ?? ''; + // Set full transcript if incoming chunk contains complete response, or append if chunked + state = state.copyWith( + aiTranscript: text.length >= state.aiTranscript.length ? text : (state.aiTranscript + text), + ); + } else if (type == 'ai.audio_chunk' || type == 'ai_audio_chunk') { + final turnId = (event['assistant_turn_id'] as num?)?.toInt() ?? state.assistantTurnId; + final seq = (event['sequence'] as num?)?.toInt() ?? 0; + final b64 = event['audio_base64'] as String?; + final envelope = (event['envelope'] as List?) + ?.map((e) => (e as num).toDouble()) + .toList() ?? []; + + if (b64 != null && b64.isNotEmpty) { + try { + final audioBytes = base64Decode(b64); + if (state.callState != CallState.aiSpeaking) { + enterAiSpeaking(turnId); + } + _audioPlaybackService.enqueueChunk( + bytes: audioBytes, + assistantTurnId: turnId, + sequence: seq, + envelope: envelope, + ); + } catch (e) { + debugPrint('âš ī¸ [AI AUDIO DECODE ERROR]: $e'); + } + } + } else if (type == 'ai.speech_finished' || type == 'ai_speech_finished') { + final turnId = (event['assistant_turn_id'] as num?)?.toInt() ?? state.assistantTurnId; + if (state.callState == CallState.thinking) { + // Backend finished generation but no audio was enqueued/played (TTS fallback/text-only) + _thinkingWatchdogTimer?.cancel(); + Future.delayed(const Duration(milliseconds: 1500), () async { + if (state.callState == CallState.thinking && !_isDisposed) { + state = state.copyWith(userTurnId: state.userTurnId + 1); + await enterListening(); + } + }); + } else { + _audioPlaybackService.markStreamFinished(turnId); + } + } else if (type == 'assistant.interrupted_ack' || type == 'interrupted_ack') { + debugPrint('⚡ [AI CALL CONTROLLER] Interrupted ACK confirmed by server'); + } else if (type == 'call.sync_state') { + final activeUserTurn = (event['active_user_turn_id'] as num?)?.toInt(); + final activeAssistantTurn = (event['active_assistant_turn_id'] as num?)?.toInt(); + final serverState = event['state'] as String?; + if (activeUserTurn != null) { + state = state.copyWith(userTurnId: activeUserTurn); + } + if (activeAssistantTurn != null) { + state = state.copyWith(assistantTurnId: activeAssistantTurn); + } + if (serverState == 'listening' && state.callState == CallState.thinking) { + enterListening(); + } + } else if (type == 'ai.crisis_escalation' || type == 'crisis_alert' || type == 'emergency_triggered') { + final hotline = event['hotline'] as String? ?? 'Hotline Dinkes (WhatsApp 0813-8007-3120) / Kemenkes 119 ext 8'; + final hotlineUrl = event['hotline_url'] as String? ?? + 'https://api.whatsapp.com/send/?phone=6281380073120&text=halo%20kak%2C%20saya%20ingin%20bercerita%20mengenai...&type=phone_number&app_absent=0'; + state = state.copyWith( + crisisHotline: hotline, + crisisHotlineUrl: hotlineUrl, + isCrisisSession: true, + ); + } else if (type == 'error') { + state = state.copyWith(errorMessage: event['message'] as String? ?? 'Terjadi kesalahan sistem'); + if (state.callState == CallState.thinking || state.callState == CallState.aiSpeaking) { + enterListening(); + } + } + } + + /// Invariant: LISTENING -> STT ON, AI audio OFF + Future enterListening() async { + _restartTimer?.cancel(); + _thinkingWatchdogTimer?.cancel(); + + // Guard: Audio must be completely stopped before mic opens + await _audioPlaybackService.stop(); + + final nextSessionId = state.activeSttSessionId + 1; + _restartAttempts = 0; + + state = state.copyWith( + callState: CallState.listening, + activeSttSessionId: nextSessionId, + isUserSpeaking: false, + currentTranscript: '', + latestPartial: '', + aiTranscript: '', + ); + + if (state.conversationMode == ConversationMode.hybridAuto && !state.isMuted) { + await _speechService.startListening(sttSessionId: nextSessionId); + } + } + + /// Invariant: THINKING -> STT OFF, AI audio OFF + Future enterThinking() async { + _restartTimer?.cancel(); + _thinkingWatchdogTimer?.cancel(); + state = state.copyWith( + callState: CallState.thinking, + isUserSpeaking: false, + soundLevel: 0.05, + ); + await _speechService.stopListening(); + + // Watchdog Timer: Guard against stuck thinking if backend stalls or drops connection + _thinkingWatchdogTimer = Timer(const Duration(seconds: 15), () async { + if (state.callState == CallState.thinking && !_isDisposed) { + debugPrint('âš ī¸ [THINKING WATCHDOG TIMEOUT]: No AI response within 15s. Recovering to listening.'); + state = state.copyWith( + errorMessage: 'Respons Eva membutuhkan waktu lebih lama. Silakan coba bicara lagi.', + ); + await enterListening(); + } + }); + } + + /// Invariant: AI_SPEAKING -> STT OFF, AI audio ON + Future enterAiSpeaking(int assistantTurnId) async { + _restartTimer?.cancel(); + _thinkingWatchdogTimer?.cancel(); + state = state.copyWith( + callState: CallState.aiSpeaking, + assistantTurnId: assistantTurnId, + isUserSpeaking: false, + soundLevel: 0.05, + ); + _audioPlaybackService.prepareNewAssistantTurn(assistantTurnId); + await _speechService.stopListening(); + } + + /// Normal Automatic Flow: AI playback finished -> guard delay -> auto LISTENING + Future handlePlaybackFinished(int finishedTurnId) async { + if (state.callState != CallState.aiSpeaking) return; + + debugPrint('🔊 [PLAYBACK FINISHED]: Guard delay before auto-listening...'); + _wsClient.sendPlaybackFinished( + callId: state.callId, + assistantTurnId: finishedTurnId, + ); + + // Guard delay between AI speaker end and mic start + await Future.delayed(const Duration(milliseconds: CallConfig.playbackToListeningGuardMs)); + + if (state.callState == CallState.aiSpeaking && !_isDisposed) { + // Ensure userTurnId advances to at least finishedTurnId + 1 without duplicate double increment + if (state.userTurnId <= finishedTurnId) { + state = state.copyWith(userTurnId: finishedTurnId + 1); + } + await enterListening(); + } + } + + /// Auto-restart STT on turn.keep_open if Android recognizer paused + void handleKeepOpen(bool restartStt, int waitMs) { + if (state.callState != CallState.listening) return; + if (!restartStt) return; + + _restartAttempts++; + if (_restartAttempts > 10) { + debugPrint('âš ī¸ [AUTO RESTART]: Max restart limit reached. Awaiting timeout or manual commit.'); + return; + } + + _restartTimer?.cancel(); + _restartTimer = Timer(const Duration(milliseconds: CallConfig.sttRestartDelayMs), () async { + if (state.callState == CallState.listening && !_isDisposed && !state.isMuted) { + final nextSessionId = state.activeSttSessionId + 1; + debugPrint('🔄 [AUTO RESTART STT]: Session $nextSessionId in user_turn ${state.userTurnId}'); + state = state.copyWith(activeSttSessionId: nextSessionId); + await _speechService.startListening(sttSessionId: nextSessionId); + } + }); + } + + /// Manual Barge-In: Interrupt ongoing AI response or pending generation + Future bargeIn() async { + // Idempotent guard: if already interrupting or listening, ignore duplicate taps + if (state.callState == CallState.interrupting || state.callState == CallState.listening) { + debugPrint('⚡ [BARGE-IN IGNORED]: Already in state ${state.callState}'); + return; + } + + debugPrint('⚡ [MANUAL BARGE-IN TRIGGERED] State: ${state.callState}'); + + _thinkingWatchdogTimer?.cancel(); + state = state.copyWith(callState: CallState.interrupting); + + // 1. Instantly stop audio player locally and flush queue + await _audioPlaybackService.stop(); + + // 2. Notify backend to cancel active task + _wsClient.sendInterrupt( + callId: state.callId, + assistantTurnId: state.assistantTurnId, + reason: 'user_barge_in', + ); + + // 3. Short barge-in guard delay + await Future.delayed(const Duration(milliseconds: CallConfig.bargeInGuardMs)); + + if (!_isDisposed) { + // 4. Create new user turn and start listening immediately + state = state.copyWith(userTurnId: state.userTurnId + 1); + await enterListening(); + } + } + + /// Manual force-commit button in LISTENING state ("Selesai Bicara") + Future forceCommit() async { + if (state.callState != CallState.listening) return; + + debugPrint('👆 [FORCE COMMIT] Turn: ${state.userTurnId}'); + _wsClient.sendForceCommit( + callId: state.callId, + userTurnId: state.userTurnId, + ); + await enterThinking(); + } + + /// Toggle microphone mute + void toggleMute() { + final newMute = !state.isMuted; + _speechService.setMuted(newMute); + state = state.copyWith(isMuted: newMute); + + if (newMute) { + _speechService.stopListening(); + } else if (state.callState == CallState.listening) { + _speechService.startListening(sttSessionId: state.activeSttSessionId); + } + } + + /// Toggle speakerphone (Loudspeaker vs Earpiece) + void toggleSpeaker() { + final newSpeaker = !state.isSpeakerOn; + state = state.copyWith(isSpeakerOn: newSpeaker); + _audioPlaybackService.setSpeakerphoneOn(newSpeaker); + } + + /// Switch conversation mode (Hybrid Auto vs Push to Talk fallback) + void setConversationMode(ConversationMode mode) { + state = state.copyWith(conversationMode: mode); + if (mode == ConversationMode.pushToTalk && state.callState == CallState.listening) { + _speechService.stopListening(); + } else if (mode == ConversationMode.hybridAuto && state.callState == CallState.listening) { + _speechService.startListening(sttSessionId: state.activeSttSessionId); + } + } + + /// Push-to-talk press down: start listening + Future pushToTalkPress() async { + if (state.conversationMode != ConversationMode.pushToTalk) return; + if (state.callState == CallState.aiSpeaking || state.callState == CallState.thinking) { + await bargeIn(); + } else if (state.callState == CallState.listening) { + await _speechService.startListening(sttSessionId: state.activeSttSessionId); + } + } + + /// Push-to-talk release: stop listening & force commit + Future pushToTalkRelease() async { + if (state.conversationMode != ConversationMode.pushToTalk) return; + await forceCommit(); + } + + /// End Call & Disconnect + Future endCall() async { + _durationTimer?.cancel(); + _restartTimer?.cancel(); + _thinkingWatchdogTimer?.cancel(); + + await _speechService.stopListening(); + await _audioPlaybackService.stop(); + + _wsClient.sendEndCall(state.callDurationSeconds); + _wsClient.disconnect(); + + state = state.copyWith(callState: CallState.ended); + } + + void _cancelSubscriptions() { + _partialSubscription?.cancel(); + _finalSegmentSubscription?.cancel(); + _statusSubscription?.cancel(); + _soundLevelSubscription?.cancel(); + _aiAudioSoundSubscription?.cancel(); + _playbackSubscription?.cancel(); + _wsAudioSubscription?.cancel(); + _wsEventSubscription?.cancel(); + _errorSubscription?.cancel(); + } + + @override + void dispose() { + _isDisposed = true; + _cancelSubscriptions(); + _durationTimer?.cancel(); + _restartTimer?.cancel(); + _thinkingWatchdogTimer?.cancel(); + _speechService.stopListening(); + _audioPlaybackService.stop(); + _wsClient.disconnect(); + super.dispose(); + } +} diff --git a/apps/eva_mobile/lib/features/voice_call/presentation/controllers/ai_call_state.dart b/apps/eva_mobile/lib/features/voice_call/presentation/controllers/ai_call_state.dart new file mode 100644 index 0000000..9f93bfd --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/presentation/controllers/ai_call_state.dart @@ -0,0 +1,91 @@ +import '../../domain/entities/call_state.dart'; + +class AiCallViewState { + final CallState callState; + final ConversationMode conversationMode; + final String callId; + final int userTurnId; + final int activeSttSessionId; + final int assistantTurnId; + final String currentTranscript; + final String latestPartial; + final String aiTranscript; + final double soundLevel; + final int callDurationSeconds; + final bool isMuted; + final bool isSpeakerOn; + final bool isUserSpeaking; + final String? errorMessage; + final String? crisisHotline; + final String? crisisHotlineUrl; + final bool isCrisisSession; + final String voiceMode; + + const AiCallViewState({ + this.callState = CallState.idle, + this.conversationMode = ConversationMode.hybridAuto, + this.callId = '', + this.userTurnId = 1, + this.activeSttSessionId = 0, + this.assistantTurnId = 0, + this.currentTranscript = '', + this.latestPartial = '', + this.aiTranscript = '', + this.soundLevel = 0.05, + this.callDurationSeconds = 0, + this.isMuted = false, + this.isSpeakerOn = true, + this.isUserSpeaking = false, + this.errorMessage, + this.crisisHotline, + this.crisisHotlineUrl, + this.isCrisisSession = false, + this.voiceMode = 'mode_2', + }); + + AiCallViewState copyWith({ + CallState? callState, + ConversationMode? conversationMode, + String? callId, + int? userTurnId, + int? activeSttSessionId, + int? assistantTurnId, + String? currentTranscript, + String? latestPartial, + String? aiTranscript, + double? soundLevel, + int? callDurationSeconds, + bool? isMuted, + bool? isSpeakerOn, + bool? isUserSpeaking, + String? errorMessage, + String? crisisHotline, + String? crisisHotlineUrl, + bool? isCrisisSession, + String? voiceMode, + bool clearCrisis = false, + bool clearError = false, + }) { + return AiCallViewState( + callState: callState ?? this.callState, + conversationMode: conversationMode ?? this.conversationMode, + callId: callId ?? this.callId, + userTurnId: userTurnId ?? this.userTurnId, + activeSttSessionId: activeSttSessionId ?? this.activeSttSessionId, + assistantTurnId: assistantTurnId ?? this.assistantTurnId, + currentTranscript: currentTranscript ?? this.currentTranscript, + latestPartial: latestPartial ?? this.latestPartial, + aiTranscript: aiTranscript ?? this.aiTranscript, + soundLevel: soundLevel ?? this.soundLevel, + callDurationSeconds: callDurationSeconds ?? this.callDurationSeconds, + isMuted: isMuted ?? this.isMuted, + isSpeakerOn: isSpeakerOn ?? this.isSpeakerOn, + isUserSpeaking: isUserSpeaking ?? this.isUserSpeaking, + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + crisisHotline: clearCrisis ? null : (crisisHotline ?? this.crisisHotline), + crisisHotlineUrl: clearCrisis ? null : (crisisHotlineUrl ?? this.crisisHotlineUrl), + isCrisisSession: clearCrisis ? false : (isCrisisSession ?? this.isCrisisSession), + voiceMode: voiceMode ?? this.voiceMode, + ); + } +} diff --git a/apps/eva_mobile/lib/features/voice_call/presentation/providers/ai_call_provider.dart b/apps/eva_mobile/lib/features/voice_call/presentation/providers/ai_call_provider.dart new file mode 100644 index 0000000..112cb8c --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/presentation/providers/ai_call_provider.dart @@ -0,0 +1,40 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../data/datasources/voice_call_ws_client.dart'; +import '../../data/services/ai_audio_playback_service.dart'; +import '../../data/services/speech_recognition_service.dart'; +import '../controllers/ai_call_controller.dart'; +import '../controllers/ai_call_state.dart'; + +final speechRecognitionServiceProvider = + Provider.autoDispose((ref) { + final service = SpeechRecognitionService(); + ref.onDispose(() => service.dispose()); + return service; +}); + +final aiAudioPlaybackServiceProvider = + Provider.autoDispose((ref) { + final service = AiAudioPlaybackService(); + ref.onDispose(() => service.dispose()); + return service; +}); + +final voiceCallWsClientProvider = Provider.autoDispose((ref) { + final client = VoiceCallWsClient(); + ref.onDispose(() => client.dispose()); + return client; +}); + +final aiCallControllerProvider = + StateNotifierProvider.autoDispose((ref) { + final speech = ref.watch(speechRecognitionServiceProvider); + final playback = ref.watch(aiAudioPlaybackServiceProvider); + final ws = ref.watch(voiceCallWsClientProvider); + + return AiCallController( + speechService: speech, + audioPlaybackService: playback, + wsClient: ws, + ); +}); 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 new file mode 100644 index 0000000..1027198 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/presentation/providers/voice_call_provider.dart @@ -0,0 +1,17 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/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'; + +final voiceCallRepositoryProvider = Provider((ref) { + if (AppConfig.useMockData) { + return VoiceCallRepositoryImpl(MockVoiceCallDataSource()); + } else { + return VoiceCallRepositoryImpl(RemoteVoiceCallDataSource()); + } +}); + +final activeVoiceSessionProvider = StateProvider((ref) => null); diff --git a/apps/eva_mobile/lib/main.dart b/apps/eva_mobile/lib/main.dart new file mode 100644 index 0000000..6371be2 --- /dev/null +++ b/apps/eva_mobile/lib/main.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'core/routes/app_router.dart'; +import 'theme/app_theme.dart'; +import 'widgets/web_phone_frame_wrapper.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + // Asynchronously pre-warm Inter font cache to prevent frame drop on initial screen render + GoogleFonts.pendingFonts([ + GoogleFonts.inter(), + ]); + runApp(const ProviderScope(child: EvaApp())); +} + +class EvaApp extends StatelessWidget { + const EvaApp({super.key}); + + @override + Widget build(BuildContext context) { + return ProviderScope( + child: MaterialApp( + title: 'EVA AI - Mental Health Companion', + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme, + initialRoute: '/', + onGenerateRoute: AppRouter.onGenerateRoute, + builder: (context, child) { + return WebPhoneFrameWrapper( + child: child ?? const SizedBox.shrink(), + ); + }, + ), + ); + } +} + +// 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 new file mode 100644 index 0000000..1674e5b --- /dev/null +++ b/apps/eva_mobile/lib/providers/daily_progress_provider.dart @@ -0,0 +1,158 @@ +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 '../services/daily_progress_local_service.dart'; + +/// Notifier managing real-time reactive updates for the user's daily self-care progress. +class DailyProgressNotifier extends StateNotifier { + StreamSubscription? _streamSub; + + DailyProgressNotifier() : super(DailyProgressData(date: DailyProgressLocalService.getTodayWibDate())) { + _init(); + } + + void _init() { + // 1. Listen to broadcast stream from local persistence service + _streamSub = DailyProgressLocalService.progressStream.listen((data) { + if (mounted) { + state = data; + } + }); + + // 2. Load cached progress immediately + loadProgress(); + + // 3. Background sync with backend + syncWithServer(); + } + + /// Load today's progress from local SharedPreferences + Future loadProgress() async { + final local = await DailyProgressLocalService.loadTodayProgress(); + if (mounted) { + state = local; + } + } + + /// Record conversation check-in + Future recordConversationCheckin({bool hasConversation = true, DateTime? now}) async { + final updated = await DailyProgressLocalService.recordConversationCheckin( + hasConversation: hasConversation, + now: now, + ); + if (mounted) { + state = updated; + } + } + + /// Record diary check-in + Future recordDiaryCheckin({bool hasDiary = true, DateTime? now}) async { + final updated = await DailyProgressLocalService.recordDiaryCheckin( + hasDiary: hasDiary, + now: now, + ); + if (mounted) { + state = updated; + } + } + + /// Record activity completion + Future recordActivityCompletion(String activityId, bool isCompleted, {DateTime? now}) async { + final updated = await DailyProgressLocalService.recordActivityCompletion( + activityId, + isCompleted, + now: now, + ); + if (mounted) { + state = updated; + } + } + + /// Toggle activity completion + Future toggleActivityCompletion(String activityId, {DateTime? now}) async { + final updated = await DailyProgressLocalService.toggleActivityCompletion( + activityId, + now: now, + ); + if (mounted) { + state = updated; + } + } + + /// Synchronize today's progress with remote backend + Future 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)), + // 1: Diary today + http + .get(Uri.parse('${AppConfig.baseUrl}/diaries/today'), headers: headers) + .timeout(const Duration(seconds: 4)) + .catchError((_) => http.Response('{}', 500)), + ]); + + 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) { + final count = data['total'] ?? (data['items'] as List?)?.length ?? 0; + if (count > 0) hasConv = true; + } + } + + final resDiary = results[1]; + if (resDiary.statusCode == 200) { + final data = jsonDecode(resDiary.body); + if (data is Map && data['id'] != null) { + hasDiary = true; + } + } + + if (hasConv != state.hasConversation || hasDiary != state.hasDiary) { + DailyProgressData updated = state; + if (hasConv) { + updated = await DailyProgressLocalService.recordConversationCheckin(hasConversation: true); + } + if (hasDiary) { + updated = await DailyProgressLocalService.recordDiaryCheckin(hasDiary: true); + } + if (mounted) { + state = updated; + } + } + } catch (_) { + // Graceful offline fallback + } + } + + /// Refresh progress from both local and server + Future refresh() async { + await loadProgress(); + await syncWithServer(); + } + + @override + void dispose() { + _streamSub?.cancel(); + super.dispose(); + } +} + +/// Global Riverpod provider for daily self-care progress. +final dailyProgressProvider = + StateNotifierProvider((ref) { + return DailyProgressNotifier(); +}); diff --git a/apps/eva_mobile/lib/screens/ai_conversation_screen.dart b/apps/eva_mobile/lib/screens/ai_conversation_screen.dart new file mode 100644 index 0000000..0a20958 --- /dev/null +++ b/apps/eva_mobile/lib/screens/ai_conversation_screen.dart @@ -0,0 +1,883 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; + +import '../config/app_config.dart'; +import '../theme/app_colors.dart'; +import '../widgets/custom_button.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/staggered_entrance.dart'; + +class AiConversationScreen extends StatefulWidget { + const AiConversationScreen({super.key}); + + @override + State createState() => _AiConversationScreenState(); +} + +class _AiConversationScreenState extends State { + final ScrollController _scrollController = ScrollController(); + List> _conversations = []; + bool _isLoading = true; + bool _isLoadingMore = false; + int _currentPage = 1; + static const int _pageSize = 10; + bool _hasMore = true; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _fetchConversations(isRefresh: true); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.hasClients && + _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + if (!_isLoading && !_isLoadingMore && _hasMore) { + _fetchMoreConversations(); + } + } + } + + Future _fetchConversations({bool isRefresh = false}) async { + if (isRefresh) { + setState(() { + _isLoading = true; + _currentPage = 1; + _hasMore = true; + }); + } + + try { + final headers = await AppConfig.getAuthHeaders(); + final res = await http + .get( + Uri.parse('${AppConfig.baseUrl}/conversations?page=1&limit=$_pageSize'), + headers: headers, + ) + .timeout(const Duration(seconds: 4)); + + if (res.statusCode == 200 && mounted) { + final data = jsonDecode(res.body); + List items = []; + bool hasMore = false; + + if (data is Map) { + items = data['items'] as List? ?? []; + if (data['pagination'] is Map) { + hasMore = data['pagination']['has_more'] == true; + } else if (data['has_more'] is bool) { + hasMore = data['has_more'] as bool; + } + } else if (data is List) { + items = data; + hasMore = items.length >= _pageSize; + } + + setState(() { + _conversations = List>.from(items); + _hasMore = hasMore; + _currentPage = 1; + }); + } + } catch (_) {} finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _fetchMoreConversations() async { + if (_isLoading || _isLoadingMore || !_hasMore) return; + + setState(() => _isLoadingMore = true); + final nextPage = _currentPage + 1; + + try { + final headers = await AppConfig.getAuthHeaders(); + final res = await http + .get( + Uri.parse('${AppConfig.baseUrl}/conversations?page=$nextPage&limit=$_pageSize'), + headers: headers, + ) + .timeout(const Duration(seconds: 4)); + + if (res.statusCode == 200 && mounted) { + final data = jsonDecode(res.body); + List items = []; + bool hasMore = false; + + if (data is Map) { + items = data['items'] as List? ?? []; + if (data['pagination'] is Map) { + hasMore = data['pagination']['has_more'] == true; + } else if (data['has_more'] is bool) { + hasMore = data['has_more'] as bool; + } + } else if (data is List) { + items = data; + hasMore = items.length >= _pageSize; + } + + setState(() { + _conversations.addAll(List>.from(items)); + _currentPage = nextPage; + _hasMore = hasMore; + }); + } + } catch (_) {} finally { + if (mounted) setState(() => _isLoadingMore = 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: '😐'); + } + + @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 + + Padding( + + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + + child: Row( + + children: [ + + IconButton( + + icon: const Icon(Icons.arrow_back), + + color: AppColors.textPrimary, + + onPressed: () { + + Navigator.pop(context); + + }, + + ), + + Image.asset( + + 'assets/images/eva_logo.png', + + width: 32, + + height: 32, + + fit: BoxFit.contain, + + errorBuilder: (context, error, stackTrace) { + + return const Icon( + + Icons.nightlight_round, + + size: 28, + + color: AppColors.primary, + + ); + + }, + + ), + + const SizedBox(width: 8), + + Text( + + 'Sesi Suara EVA', + + style: GoogleFonts.inter( + + fontSize: 16, + + fontWeight: FontWeight.w700, + + color: AppColors.primary, + + ), + + ), + + ], + + ), + + ), + + 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( + text: 'Mulai Sesi Suara', + suffixIcon: Icons.mic, + onPressed: () async { + await Navigator.pushNamed(context, '/voice_call'); + if (mounted) { + _fetchConversations(isRefresh: true); + } + }, + ), + ), + 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]), + ), + ), + if (_isLoadingMore) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + ), + ) + else if (!_hasMore && _conversations.length >= _pageSize) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: Text( + 'Semua riwayat telah dimuat', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textLight, + ), + ), + ), + ), + ], + ) + : 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), + ], + ), + ), + ), + ), + + ], + + ), + + ), + + ), + + ); + + } + + Widget _buildSessionCard(BuildContext context, Map 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, + ), + ], + ), + ); + } +} + +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 _animation; + + static const List _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(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, + ), + ), + ], + ), + ), + ); + }), + ); + }, + ); + } +} + +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 _fadeAnimation; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 320), + ); + + _fadeAnimation = CurvedAnimation( + parent: _animController, + curve: Curves.easeOut, + ); + + _slideAnimation = Tween( + 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/screens/ai_diary_detail_screen.dart b/apps/eva_mobile/lib/screens/ai_diary_detail_screen.dart new file mode 100644 index 0000000..10d66b1 --- /dev/null +++ b/apps/eva_mobile/lib/screens/ai_diary_detail_screen.dart @@ -0,0 +1,1772 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; +import '../config/app_config.dart'; +import '../theme/app_colors.dart'; +import '../widgets/custom_button.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/staggered_entrance.dart'; + +Color _parseColor(dynamic colorVal, Color fallback) { + if (colorVal is Color) return colorVal; + if (colorVal is String && colorVal.startsWith('#')) { + final hex = colorVal.replaceAll('#', ''); + if (hex.length == 6) { + try { + return Color(int.parse('FF$hex', radix: 16)); + } catch (_) {} + } + } + return fallback; +} + +class AiDiaryDetailScreen extends StatefulWidget { + final Map? journalData; + + const AiDiaryDetailScreen({super.key, this.journalData}); + + @override + State createState() => _AiDiaryDetailScreenState(); +} + +class _AiDiaryDetailScreenState extends State { + late String _selectedSessionId; + bool _isInitialized = false; + Map? _diaryData; + bool _isTranscriptExpanded = false; + + // Streaming and Skeleton State + bool _isStreaming = false; + bool _isLoadingSkeleton = false; + bool _streamError = false; + String? _streamedSummary; + http.Client? _streamClient; + + @override + void dispose() { + _streamClient?.close(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + final args = widget.journalData ?? + (ModalRoute.of(context)?.settings.arguments as Map?); + if (args != null) { + _diaryData = Map.from(args); + if (args['selectedSessionId'] != null) { + _selectedSessionId = args['selectedSessionId'].toString(); + } else { + _selectedSessionId = '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 _streamSummary(String conversationId) async { + if (AppConfig.useMockData) { + setState(() { + _isLoadingSkeleton = true; + _isStreaming = true; + _streamError = false; + _streamedSummary = null; + }); + await Future.delayed(const Duration(milliseconds: 600)); + if (!mounted) return; + setState(() { + _isLoadingSkeleton = false; + _streamedSummary = + 'Sesi percakapan curhat bersama EVA berjalan dengan hangat dan memberikan ruang aman untuk refleksi batin.'; + _isStreaming = false; + }); + return; + } + + if (_isStreaming) return; + + setState(() { + _isLoadingSkeleton = true; + _isStreaming = true; + _streamError = false; + _streamedSummary = null; + }); + + try { + 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); + + _streamClient?.close(); + _streamClient = http.Client(); + final streamedResponse = + await _streamClient!.send(request).timeout(const Duration(seconds: 25)); + + if (streamedResponse.statusCode != 200) { + throw Exception('Stream endpoint returned status ${streamedResponse.statusCode}'); + } + + final stream = streamedResponse.stream + .transform(utf8.decoder) + .transform(const LineSplitter()); + + await for (final line in stream) { + if (!mounted) break; + final trimmed = line.trim(); + if (trimmed.startsWith('data:')) { + final jsonStr = trimmed.substring(5).trim(); + if (jsonStr.isEmpty) continue; + try { + final Map payload = jsonDecode(jsonStr); + if (payload.containsKey('token')) { + final token = payload['token']?.toString() ?? ''; + if (mounted) { + setState(() { + _isLoadingSkeleton = false; + _streamedSummary = (_streamedSummary ?? '') + token; + }); + } + } + if (payload['done'] == true) { + final finalSummary = payload['summary']?.toString(); + final newEmotion = payload['dominant_emotion']?.toString(); + final newEmoji = payload['dominant_emoji']?.toString(); + if (mounted) { + setState(() { + _isStreaming = false; + _isLoadingSkeleton = false; + if (finalSummary != null && finalSummary.isNotEmpty) { + _streamedSummary = finalSummary; + } + if (_diaryData != null) { + _diaryData!['summary'] = _streamedSummary; + if (newEmotion != null && newEmotion.isNotEmpty) { + _diaryData!['moodTag'] = newEmotion; + } + if (newEmoji != null && newEmoji.isNotEmpty) { + _diaryData!['moodEmoji'] = newEmoji; + } + } + }); + } + } + } catch (_) {} + } + } + } catch (e) { + if (mounted) { + setState(() { + _isStreaming = false; + _isLoadingSkeleton = false; + _streamError = true; + }); + } + } finally { + if (mounted && _isStreaming) { + setState(() { + _isStreaming = false; + _isLoadingSkeleton = false; + }); + } + _streamClient?.close(); + _streamClient = null; + } + } + + Future _fetchDiaryDetail(String diaryId) async { + if (AppConfig.useMockData) return; + try { + final headers = await AppConfig.getAuthHeaders(); + final response = await http + .get(Uri.parse('${AppConfig.baseUrl}/diaries/$diaryId'), headers: headers) + .timeout(const Duration(seconds: 6)); + if (response.statusCode == 200) { + final Map data = jsonDecode(response.body); + if (mounted) { + setState(() { + _diaryData = data; + }); + } + } + } catch (_) { + // Keep existing data + } + } + + @override + Widget build(BuildContext context) { + // Default fallback data if passed data is null + final args = widget.journalData ?? + (ModalRoute.of(context)?.settings.arguments as Map?); + + final data = _diaryData ?? args ?? { + + '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': Color(0xFF6C63FF)}, + + {'name': 'sadness', 'label': 'Sedih / Haru', 'emoji': '😔', 'percent': 0.20, 'color': Color(0xFF8B93FF)}, + + {'name': 'netral', 'label': 'Netral', 'emoji': '😐', 'percent': 0.15, 'color': 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': Color(0xFFA7E6FF)}, + + {'name': 'happy', 'label': 'Bahagia', 'emoji': '😃', 'percent': 0.35, 'color': Color(0xFFFFE6A7)}, + + {'name': 'fear', 'label': 'Takut / Gelisah', 'emoji': '😨', 'percent': 0.15, 'color': 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': Color(0xFF6C63FF)}, + + {'name': 'sadness', 'label': 'Sedih / Haru', 'emoji': '😔', 'percent': 0.35, 'color': Color(0xFF8B93FF)}, + + {'name': 'netral', 'label': 'Netral', 'emoji': '😐', 'percent': 0.25, 'color': 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.', + + }, + + ], + + }, + + ], + + }; + + + + final bool isSingleSession = data['isSingleSession'] == true; + final bool hasRisk = data['riskWarning'] != null && data['riskWarning']['detected'] == true; + + final List rawEvents = data['importantEvents'] is List ? data['importantEvents'] as List : []; + final List importantEvents = rawEvents.isNotEmpty + ? rawEvents.map((e) => e.toString()).toList() + : ['Sesi refleksi harian tercatat dalam sistem EVA.']; + + final List> sessions = (data['sessions'] is List && (data['sessions'] as List).isNotEmpty) + ? (data['sessions'] as List).map((e) => Map.from(e as Map)).toList() + : []; + + // Ensure _selectedSessionId is valid + if (_selectedSessionId != 'all' && sessions.isNotEmpty) { + final exists = sessions.any((s) => s['id'].toString() == _selectedSessionId); + if (!exists) { + _selectedSessionId = 'all'; + } + } + + // Compute active 7-emotion breakdown list based on selected session + List> activeEmotions = []; + if (_selectedSessionId == 'all') { + if (data['emotionsBreakdown'] is List && (data['emotionsBreakdown'] as List).isNotEmpty) { + activeEmotions = (data['emotionsBreakdown'] as List) + .map((e) => Map.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? 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.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> activeTranscripts = []; + if (_selectedSessionId == 'all') { + for (var s in sessions) { + final rawTrans = s['transcripts']; + if (rawTrans is List) { + for (var t in rawTrans) { + if (t is Map) { + activeTranscripts.add({ + ...Map.from(t), + 'sessionTitle': s['title'] ?? 'Sesi', + }); + } + } + } + } + } else { + Map? 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) { + final rawTrans = selectedSessData['transcripts']; + if (rawTrans is List) { + for (var t in rawTrans) { + if (t is Map) { + activeTranscripts.add({ + ...Map.from(t), + 'sessionTitle': selectedSessData['title'] ?? 'Sesi', + }); + } + } + } + } + } + + const int initialTranscriptLimit = 6; + final bool canExpand = activeTranscripts.length > initialTranscriptLimit; + final int displayCount = (_isTranscriptExpanded || !canExpand) + ? activeTranscripts.length + : initialTranscriptLimit; + + 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 + StaggeredEntrance( + index: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + + children: [ + + IconButton( + + icon: const Icon(Icons.arrow_back), + + color: AppColors.textPrimary, + + onPressed: () => Navigator.pop(context), + + ), + + const SizedBox(width: 4), + + Expanded( + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + Text( + data['title']?.toString() ?? 'Refleksi Harian EVA', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + 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(0xFFFFDCDD), + + borderRadius: BorderRadius.circular(12), + + ), + + child: const Icon( + + Icons.warning_amber_rounded, + + color: Color(0xFFD32F2F), + + size: 22, + + ), + + ), + + const SizedBox(width: 12), + + Expanded( + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + Text( + data['riskWarning']?['title']?.toString() ?? 'Peringatan Kesehatan Mental', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: const Color(0xFFD32F2F), + ), + ), + Text( + 'Tingkat: ${data['riskWarning']?['level']?.toString() ?? 'Sedang'}', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: const Color(0xFFE57373), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + data['riskWarning']?['message']?.toString() ?? 'Perhatikan kondisi emosionalmu hari ini.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + + const SizedBox(height: 16), + + Row( + + children: [ + + Expanded( + + child: CustomPillButton( + + text: 'Bantuan Krisis', + + height: 44, + + onPressed: () { + + Navigator.pushNamed(context, '/support'); + + }, + + ), + + ), + + const SizedBox(width: 10), + + Expanded( + + child: CustomPillButton( + + text: 'Latihan Ketenangan', + + isOutline: true, + + height: 44, + + onPressed: () { + + Navigator.pushNamed(context, '/recommendation'); + + }, + + ), + + ), + + ], + + ), + + ], + ), + ), + ), + const SizedBox(height: 20), + ], + + + + // 1. WAWASAN AI KUMULATIF + 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 #${sessions.length} (${data['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), + InkWell( + onTap: () { + final convId = data['conversationId'] ?? data['id']; + if (convId != null) { + _streamSummary(convId.toString()); + } + }, + 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( + (isSingleSession + ? (_streamedSummary ?? + data['summary'] ?? + data['aiInsight']) + : data['aiInsight']) + ?.toString() ?? + 'Analisis AI menunjukkan kondisi emosional kamu hari ini cukup stabil.', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textPrimary, + height: 1.5, + ), + ), + + ], + ), + ), + ), + const SizedBox(height: 16), + + + + if (!isSingleSession) ...[ + StaggeredEntrance( + index: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 2. PERISTIWA PENTING KUMULATIF + 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), + + ...importantEvents.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.toString(), + + style: GoogleFonts.inter( + + fontSize: 14, + + color: AppColors.textPrimary, + + ), + + ), + + ), + + ], + + ), + + ), + ), + + ], + + ), + + ), + + const SizedBox(height: 16), + + + + // 3. REFLEKSI EMOSIONAL + + 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( + data['emotionalReflection']?.toString() ?? + 'Merasa lebih tenang dan terarah setelah berefleksi bersama EVA.', + style: GoogleFonts.inter( + + fontSize: 14, + + color: AppColors.textPrimary, + + height: 1.5, + ), + ), + ], + ), + ), + 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: 38, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + ChoiceChip( + showCheckmark: false, + 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, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + side: BorderSide( + color: _selectedSessionId == 'all' ? AppColors.primary : Colors.transparent, + ), + ), + onSelected: (_) => setState(() { + _selectedSessionId = 'all'; + _isTranscriptExpanded = false; + }), + ), + const SizedBox(width: 8), + ...sessions.asMap().entries.map((entry) { + final idx = entry.key; + final sess = entry.value; + final isSel = sess['id'].toString() == _selectedSessionId; + final rawTime = sess['time']?.toString().trim(); + final timeStr = (rawTime != null && rawTime != '-' && rawTime.isNotEmpty) + ? rawTime + : 'Sesi #${idx + 1}'; + final emoji = (sess['moodEmoji'] != null && sess['moodEmoji'].toString().isNotEmpty) + ? sess['moodEmoji'].toString() + : 'đŸŽ™ī¸'; + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ChoiceChip( + showCheckmark: false, + label: Text('$emoji $timeStr'), + 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, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + side: BorderSide( + color: isSel ? AppColors.primary : Colors.transparent, + ), + ), + onSelected: (_) => setState(() { + _selectedSessionId = sess['id'].toString(); + _isTranscriptExpanded = false; + }), + ), + ); + }), + ], + ), + ), + const SizedBox(height: 20), + ], + + // 4. ANALISIS EMOSI PER SESI (7 Parameter Emosi Filtered) + 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 = _parseColor(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(emoColor), + ), + ), + + ], + + ), + + ); + + }).toList(), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + + + // 5. TRANSKRIP PERCAKAPAN SUARA (Voice-to-Text Filtered with overflow protection) + 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 && !_isTranscriptExpanded + ? '$displayCount dari ${activeTranscripts.length}' + : '${activeTranscripts.length} Dialog', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + + if (activeTranscripts.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 = activeTranscripts[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: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + isUser ? 'Pengguna' : 'EVA AI', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: isUser ? AppColors.primary : const Color(0xFF20667B), + ), + ), + if (isUser && item['emotionTag'] != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(item['emotionEmoji'] ?? '', style: const TextStyle(fontSize: 12)), + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFEADBFF), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + item['emotionTag']?.toString() ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + ], + ), + if (_selectedSessionId == 'all' && sessions.length > 1 && item['sessionTitle'] != null) ...[ + const SizedBox(height: 5), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade300, width: 0.8), + ), + child: Text( + 'đŸŽ™ī¸ ${item['sessionTitle']}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 8), + Text( + item['text']?.toString() ?? '', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + if (!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(() { + _isTranscriptExpanded = !_isTranscriptExpanded; + }); + }, + 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( + _isTranscriptExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + color: AppColors.primary, + size: 20, + ), + const SizedBox(width: 6), + Text( + _isTranscriptExpanded + ? 'Tampilkan Lebih Sedikit' + : 'Lihat Selengkapnya (${activeTranscripts.length - initialTranscriptLimit} pesan lainnya)', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ], + ], + ], + ), + ), + const SizedBox(height: 24), + + ], + + ), + + ), + + ), + + ], + + ), + + ), + + ), + + ); + + } + +} + +class _SummarySkeletonLoader extends StatefulWidget { + const _SummarySkeletonLoader(); + + @override + State<_SummarySkeletonLoader> createState() => _SummarySkeletonLoaderState(); +} + +class _SummarySkeletonLoaderState extends State<_SummarySkeletonLoader> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + _animation = Tween(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/screens/ai_diary_screen.dart b/apps/eva_mobile/lib/screens/ai_diary_screen.dart new file mode 100644 index 0000000..2211fcf --- /dev/null +++ b/apps/eva_mobile/lib/screens/ai_diary_screen.dart @@ -0,0 +1,803 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; + +import '../config/app_config.dart'; +import '../services/daily_progress_local_service.dart'; +import '../theme/app_colors.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/skeleton_shimmer.dart'; + + + +class AiDiaryScreen extends StatefulWidget { + const AiDiaryScreen({super.key}); + + @override + State createState() => AiDiaryScreenState(); +} + +class AiDiaryScreenState extends State { + + final TextEditingController _searchController = TextEditingController(); + + String _selectedFilter = 'Semua'; + + + + final List _filterCategories = const [ + + 'Semua', + + 'Tenang 😌', + + 'Cemas 😰', + + 'Bahagia 😃', + + 'Sedih 😔', + + 'Stres đŸ’Ĩ', + + ]; + + + + late List> _entries; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _entries = []; + _fetchRemoteDiaries(); + } + + void refresh() => _fetchRemoteDiaries(); + + Future _fetchRemoteDiaries() async { + if (AppConfig.useMockData) { + if (mounted) { + setState(() { + _entries = []; + _isLoading = false; + }); + } + return; + } + if (mounted) setState(() => _isLoading = true); + try { + final headers = await AppConfig.getAuthHeaders(); + final response = await http + .get(Uri.parse('${AppConfig.baseUrl}/diaries'), headers: headers) + .timeout(const Duration(seconds: 6)); + if (response.statusCode == 200) { + final List data = jsonDecode(response.body); + if (mounted) { + setState(() { + _entries = data.map((e) => Map.from(e)).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: Column( + + children: [ + + // Header Bar with Settings Gear Icon + + Padding( + + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 12.0), + + child: Row( + + children: [ + + Image.asset( + + 'assets/images/eva_logo.png', + + width: 32, + + height: 32, + + fit: BoxFit.contain, + + errorBuilder: (context, error, stackTrace) { + + return const Icon( + + Icons.nightlight_round, + + size: 28, + + color: AppColors.primary, + + ); + + }, + + ), + + const SizedBox(width: 8), + + Text( + + 'EVA', + + style: GoogleFonts.inter( + + fontSize: 16, + + fontWeight: FontWeight.w700, + + color: AppColors.primary, + + ), + + ), + + ], + + ), + + ), + + + + // Scrollable Main Content + Expanded( + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: _fetchRemoteDiaries, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + + padding: const EdgeInsets.fromLTRB(20.0, 8.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: 38, + + child: ListView.separated( + + 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, + + 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) + const _DiaryListSkeleton() + 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( + + 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 { + const _DiaryListSkeleton(); + + @override + Widget build(BuildContext context) { + return SkeletonShimmerHost( + child: Column( + children: List.generate( + 3, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 14.0), + child: GlassCard( + 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/screens/dass_assessment_screen.dart b/apps/eva_mobile/lib/screens/dass_assessment_screen.dart new file mode 100644 index 0000000..a890f93 --- /dev/null +++ b/apps/eva_mobile/lib/screens/dass_assessment_screen.dart @@ -0,0 +1,2125 @@ +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 '../core/utils/responsive_layout_helper.dart'; +import '../features/dass/domain/entities/dass_assessment_entity.dart'; +import '../features/dass/presentation/providers/dass_provider.dart'; +import '../theme/app_colors.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/skeleton_shimmer.dart'; +import '../widgets/staggered_entrance.dart'; + +class DASSAssessmentScreen extends ConsumerStatefulWidget { + final String initialPeriod; + + const DASSAssessmentScreen({ + super.key, + this.initialPeriod = 'today', + }); + + @override + ConsumerState createState() => _DASSAssessmentScreenState(); +} + +class _DASSAssessmentScreenState extends ConsumerState { + late String _currentPeriod; + String _selectedFilter = 'all'; // 'all', 'stress', 'anxiety', 'depression' + final Set _expandedItemIds = {}; + + @override + void initState() { + super.initState(); + _currentPeriod = widget.initialPeriod; + } + + Color _getScaleColor(String scale) { + switch (scale.toLowerCase()) { + case 'depression': + return const Color(0xFF74B9FF); + case 'anxiety': + return const Color(0xFF6C63FF); + case 'stress': + default: + return const Color(0xFFFF7675); + } + } + + String _getScaleLabel(String scale) { + switch (scale.toLowerCase()) { + case 'depression': + return 'Depresi'; + case 'anxiety': + return 'Kecemasan'; + case 'stress': + default: + return 'Stres'; + } + } + + Color _getConfidenceColor(double conf) { + if (conf >= 0.80) return const Color(0xFF059669); // Emerald + if (conf >= 0.50) return const Color(0xFFD97706); // Amber + return const Color(0xFF6366F1); // Indigo / Slate + } + + Color _getConfidenceBg(double conf) { + if (conf >= 0.80) return const Color(0xFFECFDF5); + if (conf >= 0.50) return const Color(0xFFFFFBEB); + return const Color(0xFFF5F3FF); + } + + String _getConfidenceLabel(double conf) { + if (conf >= 0.80) return 'Keyakinan Tinggi'; + if (conf >= 0.50) return 'Keyakinan Cukup'; + return 'Indikasi Awal'; + } + + @override + Widget build(BuildContext context) { + 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); + + 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: [ + // Top Bar + StaggeredEntrance( + index: 0, + child: _buildTopBar(context, notifier), + ), + + // Period Tab Selector (Hari Ini | Minggu Ini | Bulan Ini) + StaggeredEntrance( + index: 1, + child: _buildPeriodSelector(), + ), + + // Main Content + Expanded( + child: dassState.assessment.when( + loading: () => const _DassSkeletonLoader(), + 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 staged = dassState.stagedScores; + + // Filter items according to subscale chip + 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( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + children: [ + // Mode Banner (Editable vs Read-Only) + StaggeredEntrance( + index: 2, + child: _buildModeBanner(isEditable), + ), + const SizedBox(height: 16), + + // Subscale 3 Pillars Summary Card + StaggeredEntrance( + index: 3, + child: _buildSubscalesCard(assessment, staged), + ), + const SizedBox(height: 16), + + // Subscale Filter Chips (Semua 21 | Stres | Cemas | Depresi) + StaggeredEntrance( + index: 4, + child: _buildFilterChips(assessment.items.length), + ), + const SizedBox(height: 16), + + // 21 Questions List + 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 _buildQuestionCard(item, currentScore, isEditable, notifier); + }, + ), + ), + const SizedBox(height: 80), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ), + + // Animated Slide-Up Floating Save Bar + _buildFloatingSaveBar( + showSaveBar, + hasChanges, + isUnverified, + changedCount, + dassState, + notifier, + ), + ], + ), +); + } + + Widget _buildFloatingSaveBar( + bool showSaveBar, + bool hasChanges, + bool isUnverified, + int changedCount, + DASSAssessmentState dassState, + DASSAssessmentNotifier notifier, + ) { + 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'); + final 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: dassState.isSaving + ? null + : () async { + final scaffoldMessenger = ScaffoldMessenger.of(context); + final success = await notifier.saveAssessment(); + if (!mounted) return; + if (success) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + 'Asesmen DASS-21 hari ini berhasil diverifikasi dan disimpan!', + style: GoogleFonts.plusJakartaSans(), + ), + backgroundColor: const Color(0xFF10B981), + behavior: SnackBarBehavior.floating, + ), + ); + } + }, + 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: dassState.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, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildTopBar(BuildContext context, DASSAssessmentNotifier notifier) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20, color: Color(0xFF1E1B4B)), + ), + const SizedBox(width: 4), + 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( + 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; + + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Menganalisis dan mengekstrak DASS-21 dari riwayat dialog hari ini...', + style: GoogleFonts.plusJakartaSans(fontSize: 12), + ), + ), + ], + ), + duration: const Duration(seconds: 4), + behavior: SnackBarBehavior.floating, + ), + ); + 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: 12, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFE6F4EA), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF34A853).withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.edit_note_rounded, color: Color(0xFF137333), size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Tinjau bukti obrolan dan koreksi nilai (0–3) jika diperlukan.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: const Color(0xFF137333), + fontWeight: FontWeight.w500, + height: 1.3, + ), + ), + ), + ], + ), + ); + } else { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFE8F0FE), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFF1A73E8).withValues(alpha: 0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.lock_outline_rounded, color: Color(0xFF1A73E8), size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Mode Riwayat Agregat (Hanya Baca): Menampilkan akumulasi rata-rata indikator pada rentang ini. Pengeditan hanya dapat dilakukan pada asesmen Hari Ini.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: const Color(0xFF1A73E8), + height: 1.35, + ), + ), + ), + ], + ), + ); + } + } + + Widget _buildSubscalesCard(DASSAssessmentEntity assessment, Map stagedScores) { + 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: () => _showDassInfoDialog(context), + 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', + ), + ], + ), + ), + ], + ), + ); + } + + 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'; + } + } + + 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 _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) { + _showPsychologistReferralModal(context, label, score, scaleType); + } else { + _showSubscaleInfoModal(context, 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, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildFilterChips(int totalCount) { + final filters = [ + {'key': 'all', 'label': 'Semua ($totalCount)'}, + {'key': 'stress', 'label': 'Stres (7)'}, + {'key': 'anxiety', 'label': 'Kecemasan (7)'}, + {'key': 'depression', 'label': 'Depresi (7)'}, + ]; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: filters.map((f) { + final isSelected = _selectedFilter == f['key']; + return Padding( + padding: const EdgeInsets.only(right: 6.0), + child: BouncingButton( + onTap: () { + if (!isSelected) { + setState(() { + _selectedFilter = f['key']!; + }); + } + }, + scaleFactor: 0.92, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFE5E7EB), + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.25), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Text( + f['label']!, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? Colors.white : const Color(0xFF4B5563), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildQuestionCard( + DASSItemEntity item, + int currentScore, + bool isEditable, + DASSAssessmentNotifier notifier, + ) { + 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: () => notifier.updateScore(item.itemId, item.score), + 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(item), + ], + ), + ], + ), + 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(item), + + 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, isEditable, () => notifier.updateScore(item.itemId, 0)), + const SizedBox(width: 6), + _buildLikertButton(1, '1: Kadang', currentScore == 1, isEditable, () => notifier.updateScore(item.itemId, 1)), + const SizedBox(width: 6), + _buildLikertButton(2, '2: Sering', currentScore == 2, isEditable, () => notifier.updateScore(item.itemId, 2)), + const SizedBox(width: 6), + _buildLikertButton(3, '3: Selalu', currentScore == 3, isEditable, () => notifier.updateScore(item.itemId, 3)), + ], + ), + ), + ], + ), + ); + } + + Widget _buildAIAnalysisIconButton(DASSItemEntity item) { + final isExpanded = _expandedItemIds.contains(item.itemId); + 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: () { + setState(() { + if (isExpanded) { + _expandedItemIds.remove(item.itemId); + } else { + _expandedItemIds.add(item.itemId); + } + }); + }, + 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(DASSItemEntity item) { + final isExpanded = _expandedItemIds.contains(item.itemId); + 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 _AIAnalysisAccordion( + isExpanded: isExpanded, + item: item, + confBg: confBg, + confColor: confColor, + confPercent: confPercent, + confLabel: confLabel, + hasEvidence: hasEvidence, + hasReason: hasReason, + ); + } + + Widget _buildLikertButton( + int score, + String label, + bool isSelected, + bool isEditable, + VoidCallback onTap, + ) { + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: BouncingButton( + onTap: isEditable ? onTap : 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), + ), + ), + ), + ), + ), + ), + ); + } + + 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(); + }, + 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 _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) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tidak dapat membuka WhatsApp Konseling Krisis'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + } + + +} + +class _AIAnalysisAccordion 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 _AIAnalysisAccordion({ + 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<_AIAnalysisAccordion> createState() => _AIAnalysisAccordionState(); +} + +class _AIAnalysisAccordionState extends State<_AIAnalysisAccordion> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _expandAnimation; + late final Animation _slideAnimation; + late final Animation _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( + 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 _AIAnalysisAccordion 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), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +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 createState() => _BouncingButtonState(); +} + +class _BouncingButtonState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scale; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 90), + reverseDuration: const Duration(milliseconds: 110), + ); + _scale = Tween(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, + ), + ); + } +} + +/// Shimmer skeleton loader for the DASS-21 Assessment Screen. +class _DassSkeletonLoader extends StatelessWidget { + const _DassSkeletonLoader(); + + @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) + 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( + 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/screens/emergency_contacts_screen.dart b/apps/eva_mobile/lib/screens/emergency_contacts_screen.dart new file mode 100644 index 0000000..4d95a6a --- /dev/null +++ b/apps/eva_mobile/lib/screens/emergency_contacts_screen.dart @@ -0,0 +1,1145 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; + +import '../config/app_config.dart'; +import '../theme/app_colors.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/skeleton_shimmer.dart'; +import '../widgets/staggered_entrance.dart'; + +class EmergencyContactsScreen extends StatefulWidget { + const EmergencyContactsScreen({super.key}); + + @override + State createState() => _EmergencyContactsScreenState(); +} + +class _EmergencyContactsScreenState extends State { + List> _contacts = []; + bool _isLoading = false; + + void _copyToClipboard(String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$label ($text) berhasil disalin ke clipboard'), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + } + + @override + void initState() { + super.initState(); + _loadContacts(); + } + + Future _loadContacts() async { + setState(() => _isLoading = true); + try { + final headers = await AppConfig.getAuthHeaders(); + final res = await http + .get( + Uri.parse('${AppConfig.baseUrl}/users/emergency-contacts'), + headers: headers, + ) + .timeout(const Duration(seconds: 4)); + + if (res.statusCode == 200 && mounted) { + final data = jsonDecode(res.body); + if (data is List) { + setState(() { + _contacts = data.map>((c) => { + 'id': c['id']?.toString() ?? '', + 'name': c['name']?.toString() ?? '', + 'relation': (c['relationship'] ?? c['relation'])?.toString() ?? 'Kontak Darurat', + 'phone': (c['phone'] ?? c['phone_number'])?.toString() ?? '', + 'isPrimary': c['isPrimary'] == true || c['is_primary'] == true, + }).toList(); + }); + } + } + } catch (_) {} + finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _saveContactToBackend({ + required String name, + required String relation, + required String phone, + required bool isPrimary, + Map? 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 { + final headers = await AppConfig.getAuthHeaders(); + if (contactToEdit != null) { + // UPDATE existing contact via PUT + final contactId = contactToEdit['id']?.toString() ?? ''; + await http + .put( + Uri.parse('${AppConfig.baseUrl}/users/emergency-contacts/$contactId'), + headers: headers, + body: jsonEncode({ + 'name': name, + 'relationship': relation.isEmpty ? 'Keluarga' : relation, + 'phone_number': phone, + 'is_primary': isPrimary, + }), + ) + .timeout(const Duration(seconds: 4)); + } else { + // CREATE new contact via POST + await http + .post( + Uri.parse('${AppConfig.baseUrl}/users/emergency-contacts'), + headers: headers, + body: jsonEncode({ + 'name': name, + 'relationship': relation.isEmpty ? 'Keluarga' : relation, + 'phone_number': phone, + 'is_primary': isPrimary, + }), + ) + .timeout(const Duration(seconds: 4)); + } + _loadContacts(); + } catch (_) {} + } + + Future _deleteContactFromBackend(Map 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.length > 5) { + try { + final headers = await AppConfig.getAuthHeaders(); + await http + .delete( + Uri.parse('${AppConfig.baseUrl}/users/emergency-contacts/$contactId'), + headers: headers, + ) + .timeout(const Duration(seconds: 4)); + } catch (_) {} + } + } + + + + void _showContactFormDialog({Map? contactToEdit}) { + final isEditing = contactToEdit != null; + final nameController = + TextEditingController(text: isEditing ? contactToEdit['name'] : ''); + final relationController = + TextEditingController(text: isEditing ? contactToEdit['relation'] : ''); + final phoneController = + TextEditingController(text: isEditing ? contactToEdit['phone'] : ''); + bool isPrimary = isEditing ? (contactToEdit['isPrimary'] ?? false) : false; + + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) { + return Dialog( + backgroundColor: Colors.white, + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 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: 'Masukkan nama kontak...', + prefixIcon: Icons.person_outline_rounded, + ), + ), + const SizedBox(height: 14), + + // Input 2: Hubungan / Peran + _buildFormFieldLabel( + icon: Icons.favorite_outline_rounded, + label: 'HUBUNGAN / PERAN', + ), + const SizedBox(height: 6), + TextField( + controller: relationController, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + decoration: _buildInputDecoration( + hint: 'Contoh: Ibu, Pasangan, Dokter...', + prefixIcon: Icons.family_restroom_rounded, + ), + ), + const SizedBox(height: 14), + + // Input 3: Nomor Telepon + _buildFormFieldLabel( + icon: Icons.phone_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: 'Contoh: 0812-3456-7890', + prefixIcon: Icons.phone_iphone_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) { + setDialogState(() { + 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) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Nama dan nomor telepon wajib diisi'), + behavior: SnackBarBehavior.floating, + duration: Duration(seconds: 2), + ), + ); + return; + } + + Navigator.pop(context); + _saveContactToBackend( + name: name, + relation: relation, + phone: phone, + isPrimary: isPrimary, + contactToEdit: isEditing ? 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), + ), + ); + } + + void _confirmDeleteContact(Map 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); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('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: [ + IconButton( + icon: const Icon(Icons.arrow_back), + color: AppColors.textPrimary, + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + 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), + ), + ], + ), + ); + } +} + +/// Shimmer skeleton loader for Emergency Contacts list. +class _EmergencyContactsSkeletonLoader extends StatelessWidget { + const _EmergencyContactsSkeletonLoader(); + + @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/screens/home_screen.dart b/apps/eva_mobile/lib/screens/home_screen.dart new file mode 100644 index 0000000..d90cf89 --- /dev/null +++ b/apps/eva_mobile/lib/screens/home_screen.dart @@ -0,0 +1,2128 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; + +import '../config/app_config.dart'; +import '../core/utils/responsive_layout_helper.dart'; +import '../providers/daily_progress_provider.dart'; +import '../services/daily_progress_local_service.dart'; +import '../theme/app_colors.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/staggered_entrance.dart'; + +class HomeScreen extends ConsumerStatefulWidget { + final ValueChanged? onNavigateTab; + + const HomeScreen({ + super.key, + this.onNavigateTab, + }); + + @override + ConsumerState createState() => HomeScreenState(); +} + +class HomeScreenState extends ConsumerState with WidgetsBindingObserver { + String _userName = 'Sahabat EVA'; + int _todayConversationsCount = 0; + bool _hasTodayDiary = false; + String _moodTag = 'Tenang & Nyaman đŸŒŋ'; + 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? _todayActivity; + + 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); + } + } + + 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'; + } + + static final List> _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> _recommendations; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _recommendations = List>.from( + _defaultRecommendations.map((item) => Map.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 _loadHomeScreenData() async { + // 1. Load cached user info first for instant display + final cachedName = await AppConfig.getUserName(); + if (cachedName != null && cachedName.isNotEmpty && mounted) { + setState(() => _userName = cachedName); + } + + // 2. Load today's local self-care progress (auto-reset if new day) + final localData = await DailyProgressLocalService.loadTodayProgress(); + if (mounted) { + setState(() => _localProgress = localData); + } + + if (AppConfig.useMockData) { + if (mounted) { + setState(() { + for (var r in _recommendations) { + final rId = r['id']?.toString() ?? ''; + if (_localProgress.isActivityCompleted(rId)) { + r['isCompleted'] = true; + } + } + if (_recommendations.isNotEmpty) { + _todayActivity = _recommendations.first; + } + }); + } + return; + } + + try { + final headers = await AppConfig.getAuthHeaders(); + + // Parallel fetch across all relevant mental health endpoints + final results = await Future.wait([ + // 0: Profile + http + .get(Uri.parse('${AppConfig.baseUrl}/auth/me'), headers: headers) + .timeout(const Duration(seconds: 4)) + .catchError((_) => http.Response('{}', 500)), + // 1: Today conversations + http + .get( + Uri.parse('${AppConfig.baseUrl}/conversations/today'), + headers: headers, + ) + .timeout(const Duration(seconds: 4)) + .catchError((_) => http.Response('{}', 500)), + // 2: Today diary & emotions + http + .get(Uri.parse('${AppConfig.baseUrl}/diaries/today'), headers: headers) + .timeout(const Duration(seconds: 4)) + .catchError((_) => http.Response('{}', 500)), + // 3: Tailored recommendation for today + http + .get( + Uri.parse('${AppConfig.baseUrl}/recommendations/today'), + headers: headers, + ) + .timeout(const Duration(seconds: 4)) + .catchError((_) => http.Response('{}', 500)), + // 4: Today DASS-21 assessment + http + .get( + Uri.parse('${AppConfig.baseUrl}/dass/today'), + headers: headers, + ) + .timeout(const Duration(seconds: 4)) + .catchError((_) => http.Response('{}', 500)), + ]); + + if (!mounted) return; + + // Handle Profile + final resProfile = results[0]; + if (resProfile.statusCode == 200) { + final data = jsonDecode(resProfile.body) as Map; + final name = data['name']?.toString() ?? _userName; + _userName = name; + await AppConfig.setUserInfo( + name: name, + email: data['email']?.toString() ?? '', + ); + } + + // Handle Today Conversations + final resConversations = results[1]; + if (resConversations.statusCode == 200) { + final data = jsonDecode(resConversations.body); + if (data is Map) { + _todayConversationsCount = + data['total'] ?? (data['items'] as List?)?.length ?? 0; + if (_todayConversationsCount > 0) { + _localProgress = await DailyProgressLocalService.recordConversationCheckin( + hasConversation: true, + ); + } + if (data['mood_tag'] != null && + data['mood_tag'].toString().trim().isNotEmpty) { + _moodTag = data['mood_tag'].toString(); + } + if (data['ai_insight'] != null && + data['ai_insight'].toString().trim().isNotEmpty) { + _aiInsight = data['ai_insight'].toString(); + } + } + } + + // Handle Today Diary + final resDiary = results[2]; + if (resDiary.statusCode == 200) { + final data = jsonDecode(resDiary.body); + if (data is Map && data['id'] != null) { + _hasTodayDiary = true; + _localProgress = await DailyProgressLocalService.recordDiaryCheckin( + hasDiary: true, + ); + if (data['moodTag'] != null && + data['moodTag'].toString().trim().isNotEmpty) { + _moodTag = data['moodTag'].toString(); + } + if (data['aiInsight'] != null && + data['aiInsight'].toString().trim().isNotEmpty) { + _aiInsight = data['aiInsight'].toString(); + } + } + } + + // Handle Today Tailored Recommendation + final resRecs = results[3]; + if (resRecs.statusCode == 200) { + final dynamic data = jsonDecode(resRecs.body); + if (data is Map && data['todayActivity'] != null) { + _targetCondition = data['targetCondition']?.toString() ?? 'general'; + _conditionLabel = + data['conditionLabel']?.toString() ?? 'Fokus: Relaksasi & Perawatan Diri'; + _conditionReason = data['conditionReason']?.toString() ?? ''; + _severityLevel = data['severityLevel']?.toString() ?? 'Normal'; + _isCrisis = data['isCrisis'] == true; + if (data['crisisHotline'] != null) { + _crisisHotline = data['crisisHotline'].toString(); + } + if (data['crisisGuidance'] != null) { + _crisisGuidance = data['crisisGuidance'].toString(); + } + + final act = data['todayActivity'] as Map; + 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.from( + act['instructions'].map((e) => e.toString())) + : [], + 'rationale': act['rationale']?.toString() ?? '', + 'iconName': act['iconName']?.toString() ?? 'spa', + 'isCompleted': isActDone, + }; + + _todayActivity = actMap; + + final List> combined = [actMap]; + if (data['alternatives'] is List) { + for (var item in data['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': [], + '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 + if (results.length > 4) { + final resDass = results[4]; + if (resDass.statusCode == 200) { + final dynamic data = jsonDecode(resDass.body); + if (data is Map) { + _hasTodayDass = data['id'] != null; + _dassVerifiedByUser = + data['verifiedByUser'] == true || data['verified_by_user'] == true; + _dassStatus = data['status']?.toString() ?? ''; + _dassStressScore = + (data['stressScore'] ?? data['stress_score'] ?? 0) as int; + _dassAnxietyScore = + (data['anxietyScore'] ?? data['anxiety_score'] ?? 0) as int; + _dassDepressionScore = + (data['depressionScore'] ?? data['depression_score'] ?? 0) as int; + } + } + } + } catch (_) {} + + if (mounted) setState(() {}); + } + + Future _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 (!AppConfig.useMockData && newStatus) { + try { + final headers = await AppConfig.getAuthHeaders(); + await http + .post( + Uri.parse('${AppConfig.baseUrl}/recommendations/$id/complete'), + headers: headers, + ) + .timeout(const Duration(seconds: 4)); + } 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; + } + + @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: Column( + children: [ + // Header Bar with Logo and Settings Action + StaggeredEntrance( + index: 0, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20.0, vertical: 12.0), + child: Row( + children: [ + Image.asset( + 'assets/images/eva_logo.png', + width: 32, + height: 32, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.nightlight_round, + size: 28, + color: AppColors.primary, + ); + }, + ), + const SizedBox(width: 8), + Text( + 'EVA', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ), + + // Scrollable Body Content + Expanded( + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: _loadHomeScreenData, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20.0, 4.0, 20.0, 110.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // User Greeting Header + StaggeredEntrance( + index: 1, + 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(height: 18), + + // Empathetic AI Psychologist Insight Card + StaggeredEntrance( + index: 2, + child: _buildEvaInsightCard(), + ), + const SizedBox(height: 20), + + // Quick Navigation Shortcuts + StaggeredEntrance( + index: 3, + child: _buildQuickActionShortcuts(), + ), + const SizedBox(height: 24), + + // Evidence-Based Daily Progress Section (3 Pillars) + StaggeredEntrance( + index: 4, + child: _buildDailyProgressSection(), + ), + const SizedBox(height: 24), + + // Latest Clinically-Informed Recommendation + StaggeredEntrance( + index: 5, + child: _buildLatestRecommendationSection(), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + // -------------------------------------------------------------------------- + // 1. Empathetic Psychological Insight Card from Eva + // -------------------------------------------------------------------------- + Widget _buildEvaInsightCard() { + 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( + '"$_aiInsight"', + style: GoogleFonts.inter( + fontSize: 13, + fontStyle: FontStyle.italic, + height: 1.5, + fontWeight: FontWeight.w500, + color: const Color(0xFF333878), + ), + ), + ], + ), + ); + } + + // -------------------------------------------------------------------------- + // 2. Quick Action Shortcuts + // -------------------------------------------------------------------------- + Widget _buildQuickActionShortcuts() { + 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: () async { + await Navigator.pushNamed(context, '/chat'); + refresh(); + }, + ), + 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: () async { + if (widget.onNavigateTab != null) { + widget.onNavigateTab!(1); + } else { + await Navigator.pushNamed(context, '/diary'); + refresh(); + } + }, + ), + const SizedBox(height: 12), + _buildQuickCard( + icon: Icons.psychology_outlined, + iconBg: const Color(0xFFE0F4FB), + iconColor: const Color(0xFF20667B), + title: 'Asesmen DASS-21', + subtitle: _dassVerifiedByUser + ? 'Stres: $_dassStressScore â€ĸ Cemas: $_dassAnxietyScore â€ĸ Depresi: $_dassDepressionScore' + : (_hasTodayDass && _dassStatus == 'auto_extracted' + ? 'Tersintesis AI â€ĸ Ketuk untuk meninjau' + : 'Evaluasi & koreksi 21 butir emosi'), + showNotificationDot: _hasTodayDass && _dassStatus == 'auto_extracted' && !_dassVerifiedByUser, + onTap: () async { + await Navigator.pushNamed(context, '/dass_assessment'); + refresh(); + }, + ), + ], + ); + } + + 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, + ), + ], + ), + ); + } + + // -------------------------------------------------------------------------- + // 3. Evidence-Based Daily Progress Section (3 Pillars) + // -------------------------------------------------------------------------- + Widget _buildDailyProgressSection() { + final completed = _completedRoutinesCount; + final progressFraction = (completed / 3.0).clamp(0.0, 1.0); + + String progressMotivation; + if (completed == 3) { + progressMotivation = + 'Luar biasa! Semua target perawatan dirimu tercapai hari ini 🌟'; + } else if (completed == 2) { + progressMotivation = + 'Tinggal 1 langkah lagi untuk melengkapi rutinitas terapeutikmu.'; + } else if (completed == 1) { + progressMotivation = + 'Kemajuan yang sangat baik! Terus jaga ritme emosionalmu.'; + } else { + progressMotivation = + 'Mulai harimu dengan satu langkah kecil penuh perhatian bersama EVA.'; + } + + final bool isConversationDone = + _localProgress.hasConversation || _todayConversationsCount > 0; + final bool isDiaryDone = _localProgress.hasDiary || _hasTodayDiary; + final bool isExerciseDone = + _localProgress.completedActivityIds.isNotEmpty || + (_todayActivity != null && + _todayActivity!['isCompleted'] == true) || + _recommendations.any((r) => r['isCompleted'] == true); + + 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: completed == 3 + ? const Color(0xFF10B981).withValues(alpha: 0.15) + : AppColors.primary.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + completed == 3 ? Icons.stars_rounded : Icons.spa_rounded, + size: 16, + color: completed == 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: completed == 3 + ? const Color(0xFFD1FAE5) + : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: completed == 3 + ? const Color(0xFF10B981).withValues(alpha: 0.3) + : AppColors.primary.withValues(alpha: 0.15), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (completed == 3) ...[ + const Icon( + Icons.check_circle_rounded, + size: 13, + color: Color(0xFF047857), + ), + const SizedBox(width: 4), + ], + Text( + '$completed/3 Selesai', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: completed == 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: completed == 3 + ? const Color(0xFFF0FDF4) + : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: completed == 3 + ? const Color(0xFF86EFAC).withValues(alpha: 0.5) + : const Color(0xFFE2E8F0), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + completed == 3 + ? Icons.emoji_events_rounded + : Icons.auto_awesome, + size: 16, + color: completed == 3 + ? const Color(0xFF10B981) + : AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + progressMotivation, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: completed == 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: completed == 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: () async { + await Navigator.pushNamed(context, '/chat'); + refresh(); + }, + ), + 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: () async { + if (widget.onNavigateTab != null) { + widget.onNavigateTab!(1); + } else { + await Navigator.pushNamed(context, '/diary'); + refresh(); + } + }, + ), + 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: () async { + await Navigator.pushNamed(context, '/recommendation'); + refresh(); + }, + ), + ], + ), + ), + ], + ); + } + + Widget _buildSegmentBar({required bool isActive}) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 6, + decoration: BoxDecoration( + color: isActive ? const Color(0xFF10B981) : const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + boxShadow: isActive + ? [ + BoxShadow( + color: const Color(0xFF10B981).withValues(alpha: 0.35), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ] + : null, + ), + ); + } + + 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(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: isCompleted + ? const Color(0xFFF0FDF4) + : Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isCompleted + ? const Color(0xFF86EFAC).withValues(alpha: 0.6) + : const Color(0xFFE2E8F0), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + // Icon Container + AnimatedContainer( + duration: const Duration(milliseconds: 250), + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted + ? const Color(0xFF10B981) + : AppColors.primaryContainer, + boxShadow: isCompleted + ? [ + BoxShadow( + color: const Color(0xFF10B981).withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: Icon( + isCompleted ? Icons.check_rounded : icon, + size: isCompleted ? 18 : 17, + color: isCompleted ? Colors.white : 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, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // Action Pill / Status + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: isCompleted + ? const Color(0xFFD1FAE5) + : AppColors.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isCompleted ? 'Selesai' : 'Mulai', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: isCompleted + ? const Color(0xFF047857) + : AppColors.primary, + ), + ), + const SizedBox(width: 3), + Icon( + isCompleted + ? Icons.check_rounded + : Icons.arrow_forward_rounded, + size: 12, + color: isCompleted + ? const Color(0xFF047857) + : AppColors.primary, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + // -------------------------------------------------------------------------- + // 4. Tailored Daily Recommendation Container + // -------------------------------------------------------------------------- + Widget _buildCrisisBanner() { + 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: () { + Navigator.pushNamed(context, '/support'); + }, + 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), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildLatestRecommendationSection() { + Map 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); + 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: [ + Text( + 'Aktivitas Pilihan Hari Ini', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + GestureDetector( + onTap: () async { + await Navigator.pushNamed(context, '/recommendation'); + _loadHomeScreenData(); + }, + 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) _buildCrisisBanner(), + + // Clean, 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: [ + 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(), + 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 (clean, concise) + 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: () => + _showActivityDetailModal(context, activeRec), + 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), + Text( + isDone ? 'Buka Panduan' : 'Mulai Latihan', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF4F46E5), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: () { + if (isDone) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.info_outline, + color: Colors.white, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Kamu sudah menyelesaikan latihan ini hari ini! Kerja bagus, istirahatlah sejenak dan lanjutkan kembali besok đŸŒŋ', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500), + ), + ), + ], + ), + backgroundColor: const Color(0xFF6366F1), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + return; + } + _toggleRecommendationComplete(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.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } + + void _showActivityDetailModal( + BuildContext context, Map 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) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.info_outline, + color: Colors.white, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Kamu sudah menyelesaikan latihan ini hari ini! Kerja bagus, istirahatlah sejenak dan lanjutkan kembali besok đŸŒŋ', + style: GoogleFonts.inter( + fontSize: 12, fontWeight: FontWeight.w500), + ), + ), + ], + ), + backgroundColor: const Color(0xFF6366F1), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + Navigator.pop(ctx); + return; + } + await _toggleRecommendationComplete(id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, + color: Colors.white, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Bagus sekali! Satu langkah kecil untuk ketenanganmu telah selesai.', + style: GoogleFonts.inter( + fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + ], + ), + backgroundColor: const Color(0xFF10B981), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + Navigator.pop(ctx); + } + }, + ); + }, + ); + } +} + +/// Modal bottom sheet content for home screen with rationale, steps, and breath pacer +class _HomeActivityModalContent extends StatefulWidget { + final Map activity; + final String conditionReason; + final bool isCompleted; + final VoidCallback onComplete; + + const _HomeActivityModalContent({ + 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 instructions = rawInstructions is List + ? List.from(rawInstructions.map((e) => e.toString())) + : []; + + 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/screens/login_screen.dart b/apps/eva_mobile/lib/screens/login_screen.dart new file mode 100644 index 0000000..6c85df0 --- /dev/null +++ b/apps/eva_mobile/lib/screens/login_screen.dart @@ -0,0 +1,306 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; + +import '../config/app_config.dart'; +import '../theme/app_colors.dart'; +import '../widgets/custom_button.dart'; +import '../widgets/custom_text_field.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/social_auth_dev_sheet.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _handleLogin() async { + final email = _emailController.text.trim().toLowerCase(); + final password = _passwordController.text.trim(); + + if (email.isEmpty || password.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Silakan masukkan email dan kata sandi')), + ); + return; + } + + setState(() => _isLoading = true); + + try { + final response = await http + .post( + Uri.parse('${AppConfig.baseUrl}/auth/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'email': email, + 'password': password, + }), + ) + .timeout(const Duration(seconds: 8)); + + if (!mounted) return; + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + final token = data['access_token'] as String?; + final refreshToken = data['refresh_token'] as String?; + if (token != null) { + if (refreshToken != null) { + await AppConfig.setTokens(accessToken: token, refreshToken: refreshToken); + } else { + await AppConfig.setToken(token); + } + } + final userData = data['user'] as Map?; + await AppConfig.setUserInfo( + name: userData?['name']?.toString() ?? email.split('@').first, + email: userData?['email']?.toString() ?? email, + ); + if (!mounted) return; + Navigator.pushReplacementNamed(context, '/home'); + } else { + String msg = 'Email atau kata sandi tidak valid'; + try { + final data = jsonDecode(response.body); + if (data is Map && data['detail'] != null) { + msg = data['detail'].toString(); + } + } catch (_) { + if (response.statusCode >= 500) { + msg = 'Server sedang mengalami kendala atau pemeliharaan (HTTP ${response.statusCode}). Silakan coba beberapa saat lagi.'; + } else { + msg = 'Terjadi kesalahan pada server (HTTP ${response.statusCode})'; + } + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red.shade700, + content: Text(msg), + ), + ); + } + } catch (e) { + if (!mounted) return; + final errorMsg = e is FormatException + ? 'Respon server tidak valid. Server mungkin sedang dalam pemeliharaan.' + : '$e'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red.shade700, + content: Text('Gagal terhubung ke server (${AppConfig.host}): $errorMsg'), + 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), + + // Forgot Password Link + Align( + alignment: Alignment.centerRight, + child: GestureDetector( + onTap: () {}, + child: Text( + 'Lupa Kata Sandi?', + 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/screens/main_shell_screen.dart b/apps/eva_mobile/lib/screens/main_shell_screen.dart new file mode 100644 index 0000000..9443216 --- /dev/null +++ b/apps/eva_mobile/lib/screens/main_shell_screen.dart @@ -0,0 +1,345 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../theme/app_colors.dart'; +import '../widgets/floating_nav_bar.dart'; +import 'ai_diary_screen.dart'; +import 'home_screen.dart'; +import 'monitoring_screen.dart'; +import 'profile_screen.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 StatefulWidget { + final int initialIndex; + + const MainShellScreen({ + super.key, + this.initialIndex = 0, + }); + + @override + State createState() => _MainShellScreenState(); +} + +class _MainShellScreenState extends State { + late int _currentIndex; + bool _isNavBarVisible = true; + late final PageController _pageController; + + final GlobalKey _homeKey = GlobalKey(); + final GlobalKey _diaryKey = GlobalKey(); + final GlobalKey _monitoringKey = GlobalKey(); + final GlobalKey _profileKey = GlobalKey(); + + late final List _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), + ), + _KeepAliveWrapper( + child: MonitoringScreen(key: _monitoringKey), + ), + _KeepAliveWrapper( + child: ProfileScreen(key: _profileKey), + ), + ]; + } + + void _onTabTapped(int index) { + if (index == _currentIndex) { + // Tapping same tab = refresh + _triggerRefresh(index); + return; + } + + setState(() { + _currentIndex = index; + _isNavBarVisible = true; + }); + + _pageController + .animateToPage( + index, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ) + .then((_) { + if (mounted) { + _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: + _profileKey.currentState?.refresh(); + break; + default: + break; + } + } + + Future _showExitConfirmationDialog() async { + final result = await showDialog( + 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) { + 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( + onNotification: (notification) { + if (notification.direction == ScrollDirection.reverse && + _isNavBarVisible) { + 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, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/screens/monitoring_screen.dart b/apps/eva_mobile/lib/screens/monitoring_screen.dart new file mode 100644 index 0000000..ba3be93 --- /dev/null +++ b/apps/eva_mobile/lib/screens/monitoring_screen.dart @@ -0,0 +1,1800 @@ +import 'dart:math'; +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 '../core/utils/responsive_layout_helper.dart'; +import '../features/dass/presentation/providers/dass_provider.dart'; +import '../features/monitoring/domain/entities/monitoring_data_entity.dart'; +import '../features/monitoring/presentation/providers/monitoring_provider.dart'; +import '../theme/app_colors.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/skeleton_shimmer.dart'; + +class MonitoringScreen extends ConsumerStatefulWidget { + const MonitoringScreen({super.key}); + + @override + ConsumerState createState() => MonitoringScreenState(); +} + +class MonitoringScreenState extends ConsumerState + with WidgetsBindingObserver { + @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> _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 _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: Column( + children: [ + // Header Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 12.0), + child: Row( + children: [ + Image.asset( + 'assets/images/eva_logo.png', + width: 32, + height: 32, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.nightlight_round, + size: 28, + color: AppColors.primary, + ); + }, + ), + const SizedBox(width: 8), + Text( + 'EVA', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + ), + + // Scrollable Content with Pull-to-Refresh + Expanded( + 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, 8.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 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. 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(), + ), + ], + ), + ), + const SizedBox(height: 16), + + // 3. 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(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), + + // 4. 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, + ), + ); + }), + ), + ], + ), + ), + // 5. KARTU RINGKASAN & AKSES LEMBAR DASS-21 (Hanya muncul jika ada asesmen DASS riil) + _buildDASSLauncherCard(context, ref.watch(selectedPeriodProvider)), + ], + ); + } + + Widget _buildDASSLauncherCard(BuildContext context, String selectedPeriod) { + 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, + selectedPeriod: selectedPeriod, + subtitleText: subtitleText, + hasValidAssessment: hasValidAssessment, + stressScore: stressScore, + stressSeverity: stressSeverity, + anxietyScore: anxietyScore, + anxietySeverity: anxietySeverity, + depressionScore: depressionScore, + depressionSeverity: depressionSeverity, + ); + }, + loading: () => _buildDASSLauncherCardContainer( + context: context, + 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, + 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 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 Padding( + padding: const EdgeInsets.only(top: 16.0), + child: 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 { + await Navigator.pushNamed( + context, + '/dass_assessment', + arguments: {'period': selectedPeriod}, + ); + refresh(); + }, + 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(); + }, + 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, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLoadingState() { + return SkeletonShimmerHost( + child: Column( + children: [ + // 1. Clinical Status Header 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: 130, height: 15), + SizedBox(height: 6), + SkeletonLine(width: 80, height: 12), + ], + ), + const Spacer(), + const SkeletonBox(width: 60, height: 24, borderRadius: 12), + ], + ), + const SizedBox(height: 16), + const SkeletonLine(width: double.infinity, height: 13), + const SizedBox(height: 6), + const SkeletonLine(width: 200, height: 13), + ], + ), + ), + const SizedBox(height: 18), + + // 2. Trend Chart Skeleton + 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), + + // 3. Dual Metrics Skeleton + 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), + ), + ), + ), + ], + ), + ); + } + + 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 _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) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tidak dapat membuka WhatsApp Konseling Krisis'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + } +} + +/// Custom Painter for 7-Emotion Vertical Stacked Bar Chart +class _StackedEmotionChartPainter extends CustomPainter { + final List> chartData; + final List 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; + } + + for (int j = 0; j < segments.length; j++) { + final double ratio = segments[j]; + if (ratio <= 0) continue; + + final double segHeight = (size.height - 4) * (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; + + final RRect rrect = RRect.fromRectAndCorners( + rect, + topLeft: Radius.circular(j == 0 ? 6 : 0), + topRight: Radius.circular(j == 0 ? 6 : 0), + bottomLeft: Radius.circular(j == segments.length - 1 ? 6 : 0), + bottomRight: Radius.circular(j == segments.length - 1 ? 6 : 0), + ); + + canvas.drawRRect(rrect, paint); + currentBottom = top; + } + } + } + + @override + bool shouldRepaint(covariant _StackedEmotionChartPainter oldDelegate) { + return oldDelegate.chartData != chartData; + } +} diff --git a/apps/eva_mobile/lib/screens/profile_screen.dart b/apps/eva_mobile/lib/screens/profile_screen.dart new file mode 100644 index 0000000..671b7d0 --- /dev/null +++ b/apps/eva_mobile/lib/screens/profile_screen.dart @@ -0,0 +1,858 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; + +import '../config/app_config.dart'; +import '../theme/app_colors.dart'; +import '../widgets/custom_button.dart'; +import '../widgets/glass_card.dart'; + +class ProfileScreen extends StatefulWidget { + const ProfileScreen({super.key}); + + @override + State createState() => ProfileScreenState(); +} + +class ProfileScreenState extends State { + String _userName = 'Pengguna EVA'; + String _userEmail = ''; + 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 _loadProfileData() async { + // 1. Cached profile info + final cachedName = await AppConfig.getUserName(); + final cachedEmail = await AppConfig.getUserEmail(); + if (mounted && cachedName != null && cachedName.isNotEmpty) { + setState(() { + _userName = cachedName; + if (cachedEmail != null && cachedEmail.isNotEmpty) { + _userEmail = cachedEmail; + } + }); + } + + // 2. Fetch /auth/me + try { + final headers = await AppConfig.getAuthHeaders(); + final res = await http + .get( + Uri.parse('${AppConfig.baseUrl}/auth/me'), + headers: headers, + ) + .timeout(const Duration(seconds: 4)); + + if (res.statusCode == 200 && mounted) { + final data = jsonDecode(res.body) as Map; + final name = data['name']?.toString() ?? _userName; + final email = data['email']?.toString() ?? _userEmail; + setState(() { + _userName = name; + _userEmail = email; + }); + await AppConfig.setUserInfo(name: name, email: email); + } + } catch (_) {} + + // 3. Fetch primary emergency contact + try { + final headers = await AppConfig.getAuthHeaders(); + final res = await http + .get( + Uri.parse('${AppConfig.baseUrl}/users/emergency-contacts'), + headers: headers, + ) + .timeout(const Duration(seconds: 4)); + + if (res.statusCode == 200 && mounted) { + final data = jsonDecode(res.body); + if (data is List && data.isNotEmpty) { + final primary = data.firstWhere( + (c) => c['isPrimary'] == true, + orElse: () => data.first, + ); + setState(() { + final rel = primary['relationship'] != null ? ' (${primary['relationship']})' : ''; + _emergencyContactName = '${primary['name']}$rel'; + _emergencyContactPhone = primary['phone']?.toString() ?? ''; + }); + } else { + setState(() { + _emergencyContactName = null; + _emergencyContactPhone = null; + }); + } + } + } catch (_) {} + } + + void _showDeleteMemoryConfirmationDialog() { + + 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 catatan memori AI? EVA akan menghapus riwayat konteks emosional yang telah dipelajari.', + + 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); + + ScaffoldMessenger.of(context).showSnackBar( + + const SnackBar( + + content: Text('Seluruh catatan memori AI telah berhasil dihapus.'), + + backgroundColor: Color(0xFFD32F2F), + + ), + + ); + + }, + + child: Text( + + 'Ya, Hapus Memori', + + 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 Brand Title EVA & Settings Icon + + Padding( + + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 12.0), + + child: Row( + + children: [ + + Image.asset( + + 'assets/images/eva_logo.png', + + width: 32, + + height: 32, + + fit: BoxFit.contain, + + errorBuilder: (context, error, stackTrace) { + + return const Icon( + + Icons.nightlight_round, + + size: 28, + + color: AppColors.primary, + + ); + + }, + + ), + + const SizedBox(width: 8), + + Text( + + 'EVA', + + style: GoogleFonts.inter( + + fontSize: 16, + + fontWeight: FontWeight.w700, + + color: AppColors.primary, + + ), + + ), + + ], + + ), + + ), + + + + // Scrollable Content Body + + Expanded( + + child: SingleChildScrollView( + + padding: const EdgeInsets.fromLTRB(20.0, 8.0, 20.0, 100.0), + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + // User Profile Header Glass Card + + GlassCard( + + width: double.infinity, + + padding: const EdgeInsets.all(20), + + child: Row( + + children: [ + + Container( + + width: 60, + + height: 60, + + decoration: const BoxDecoration( + + shape: BoxShape.circle, + + gradient: LinearGradient( + + colors: [Color(0xFF8B93FF), Color(0xFF5358CB)], + + ), + + ), + + child: Center( + child: Text( + _userInitials, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + 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: 2), + Text( + _userEmail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + + const SizedBox(height: 6), + + Container( + + padding: const EdgeInsets.symmetric( + + horizontal: 10, + + vertical: 3, + + ), + + decoration: BoxDecoration( + + color: AppColors.primaryContainer, + + borderRadius: BorderRadius.circular(999), + + ), + + child: Text( + + 'ANGGOTA PREMIUM', + + style: GoogleFonts.inter( + + fontSize: 9, + + fontWeight: FontWeight.w700, + + color: AppColors.primary, + + ), + + ), + + ), + + ], + + ), + + ), + + ], + + ), + + ), + + const SizedBox(height: 24), + + + + // Section 1: AI & PRIVASI (No Toggle ON, Only Delete Button) + + 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), + + + + // Section 2: 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), + + // Section 3: 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), + + // Red Outlined 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), + + ], + + ), + + ), + + ), + + ], + + ), + + ), + + ), + + ); + + } + +} + diff --git a/apps/eva_mobile/lib/screens/recommendation_screen.dart b/apps/eva_mobile/lib/screens/recommendation_screen.dart new file mode 100644 index 0000000..546970b --- /dev/null +++ b/apps/eva_mobile/lib/screens/recommendation_screen.dart @@ -0,0 +1,1792 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; + +import '../config/app_config.dart'; +import '../services/daily_progress_local_service.dart'; +import '../theme/app_colors.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/skeleton_shimmer.dart'; +import '../widgets/staggered_entrance.dart'; + +class RecommendationScreen extends StatefulWidget { + const RecommendationScreen({super.key}); + + @override + State createState() => _RecommendationScreenState(); +} + +class _RecommendationScreenState extends State { + static final List> _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? _todayActivity; + List> _recommendations = []; + String _selectedCategory = 'Semua'; + bool _isLoading = false; + + final List _categories = [ + 'Semua', + 'Mindfulness', + 'CBT', + 'Refleksi', + 'Fisik & Gerak', + ]; + + @override + void initState() { + super.initState(); + _fetchRecommendations(); + } + + Future _fetchRecommendations() async { + final localProgress = await DailyProgressLocalService.loadTodayProgress(); + + if (AppConfig.useMockData) { + setState(() { + _recommendations = _defaultRecommendations.map((r) { + final copy = Map.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, + ); + }); + return; + } + + setState(() => _isLoading = true); + try { + final headers = await AppConfig.getAuthHeaders(); + final response = await http + .get( + Uri.parse('${AppConfig.baseUrl}/recommendations/today'), + headers: headers, + ) + .timeout(const Duration(seconds: 5)); + + if (response.statusCode == 200) { + final Map data = jsonDecode(response.body); + if (mounted) { + final rawToday = data['todayActivity'] as Map?; + final rawAll = (data['allRecommendations'] as List?) ?? + (data['alternatives'] as List?) ?? + []; + + Map? mappedToday; + if (rawToday != null) { + final tId = rawToday['id']?.toString() ?? ''; + mappedToday = _normalizeActivity(rawToday, localProgress.isActivityCompleted(tId)); + } + + final List> mappedList = []; + if (mappedToday != null) { + mappedList.add(mappedToday); + } + + for (var item in rawAll) { + if (item is Map) { + 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.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 _normalizeActivity(Map raw, bool isDone) { + final actId = raw['id']?.toString() ?? ''; + final instructionsRaw = raw['instructions']; + List 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 _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) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.white, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Kamu sudah menyelesaikan latihan ini hari ini! Kerja bagus, istirahatlah sejenak dan lanjutkan kembali besok đŸŒŋ', + style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + ], + ), + backgroundColor: const Color(0xFF6366F1), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + } + 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) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Bagus sekali! Satu langkah kecil untuk ketenanganmu telah selesai.', + style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + ], + ), + backgroundColor: const Color(0xFF10B981), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + } + + if (AppConfig.useMockData) return; + try { + final headers = await AppConfig.getAuthHeaders(); + await http.post( + Uri.parse('${AppConfig.baseUrl}/recommendations/$id/complete'), + headers: headers, + ); + } 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) + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, size: 20), + color: AppColors.textPrimary, + onPressed: () => Navigator.pop(context), + ) + 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 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 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 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 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); + }, + ); + }, + ); + } +} + +/// Dedicated stateful widget for modal with optional breath pacer +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 instructions; + final bool isBreathingActivity; + final bool isCompleted; + final VoidCallback onComplete; + + const _GuidanceModalContent({ + 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, + ), + ), + ), + ], + ), + ); + } +} + +/// Shimmer skeleton loader for the Recommendations Screen. +class _RecommendationSkeletonLoader extends StatelessWidget { + const _RecommendationSkeletonLoader(); + + @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/screens/register_screen.dart b/apps/eva_mobile/lib/screens/register_screen.dart new file mode 100644 index 0000000..25fbaa5 --- /dev/null +++ b/apps/eva_mobile/lib/screens/register_screen.dart @@ -0,0 +1,516 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + import 'package:http/http.dart' as http; + +import '../config/app_config.dart'; +import '../theme/app_colors.dart'; +import '../widgets/custom_button.dart'; +import '../widgets/custom_text_field.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/social_auth_dev_sheet.dart'; + +class RegisterScreen extends StatefulWidget { + const RegisterScreen({super.key}); + + @override + State createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends State { + 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 uri = Uri.parse('${AppConfig.baseUrl}/auth/check-email').replace( + queryParameters: {'email': cleanEmail}, + ); + final res = await http.get(uri).timeout(const Duration(seconds: 4)); + if (!mounted) return; + + if (res.statusCode == 200) { + final data = jsonDecode(res.body) as Map; + final available = data['available'] == true; + final msg = data['message']?.toString() ?? + (available ? 'Email tersedia' : 'Email sudah terdaftar'); + setState(() { + _isCheckingEmail = false; + _isEmailAvailable = available; + _emailStatusMessage = msg; + }); + } else { + setState(() { + _isCheckingEmail = false; + _isEmailAvailable = null; + _emailStatusMessage = null; + }); + } + } 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 _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) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Silakan lengkapi semua data formulir')), + ); + return; + } + + if (_isCheckingEmail) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sedang memeriksa email, mohon tunggu...')), + ); + return; + } + + if (_isEmailAvailable == false) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red.shade700, + content: Text(_emailStatusMessage ?? 'Email sudah terdaftar atau tidak valid'), + ), + ); + return; + } + + if (!_agreeTerms) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Anda harus menyetujui Syarat & Ketentuan Layanan')), + ); + return; + } + + setState(() => _isLoading = true); + + try { + final response = await http + .post( + Uri.parse('${AppConfig.baseUrl}/auth/register'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'name': name, + 'email': email, + 'password': password, + }), + ) + .timeout(const Duration(seconds: 8)); + + if (!mounted) return; + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + final token = data['access_token'] as String?; + final refreshToken = data['refresh_token'] as String?; + if (token != null) { + if (refreshToken != null) { + await AppConfig.setTokens(accessToken: token, refreshToken: refreshToken); + } else { + await AppConfig.setToken(token); + } + } + final userData = data['user'] as Map?; + await AppConfig.setUserInfo( + name: userData?['name']?.toString() ?? name, + email: userData?['email']?.toString() ?? email, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.green.shade700, + content: const Text('Akun berhasil didaftarkan! Selamat datang di EVA.'), + ), + ); + Navigator.pushReplacementNamed(context, '/home'); + } else { + String msg = 'Registrasi gagal. Silakan coba lagi.'; + try { + final data = jsonDecode(response.body); + if (data is Map && data['detail'] != null) { + msg = data['detail'].toString(); + } + } catch (_) { + if (response.statusCode >= 500) { + msg = 'Server sedang mengalami kendala atau pemeliharaan (HTTP ${response.statusCode}). Silakan coba beberapa saat lagi.'; + } else { + msg = 'Terjadi kesalahan pada server (HTTP ${response.statusCode})'; + } + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red.shade700, + content: Text(msg), + ), + ); + } + } catch (e) { + if (!mounted) return; + final errorMsg = e is FormatException + ? 'Respon server tidak valid. Server mungkin sedang dalam pemeliharaan.' + : '$e'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.red.shade700, + content: Text('Gagal menghubungi server (${AppConfig.host}): $errorMsg'), + 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/screens/settings_screen.dart b/apps/eva_mobile/lib/screens/settings_screen.dart new file mode 100644 index 0000000..34c0198 --- /dev/null +++ b/apps/eva_mobile/lib/screens/settings_screen.dart @@ -0,0 +1,942 @@ +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 '../theme/app_colors.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/skeleton_shimmer.dart'; +import '../widgets/staggered_entrance.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + // Voice & AI Preferences + String _selectedVoice = 'Eva - Jessica Ceria (Default)'; + 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 _loadSavedSettings() async { + try { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _selectedVoiceMode = prefs.getString('settings_voice_mode') ?? 'mode_2'; + _selectedVoice = prefs.getString('settings_voice_character') ?? 'Eva - Jessica Ceria (Default)'; + _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 _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 _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); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Memori percakapan AI berhasil dibersihkan.'), + backgroundColor: Color(0xFFD32F2F), + behavior: SnackBarBehavior.floating, + ), + ); + }, + 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: [ + Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.8), + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: IconButton( + icon: const Icon(Icons.arrow_back_rounded, size: 20), + color: AppColors.textPrimary, + onPressed: () => Navigator.pop(context), + ), + ), + 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 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/screens/splash_onboarding_screen.dart b/apps/eva_mobile/lib/screens/splash_onboarding_screen.dart new file mode 100644 index 0000000..a378f3a --- /dev/null +++ b/apps/eva_mobile/lib/screens/splash_onboarding_screen.dart @@ -0,0 +1,334 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; + +import '../config/app_config.dart'; +import '../theme/app_colors.dart'; +import '../widgets/custom_button.dart'; +import '../widgets/glass_card.dart'; + +class SplashOnboardingScreen extends StatefulWidget { + const SplashOnboardingScreen({super.key}); + + @override + State createState() => _SplashOnboardingScreenState(); +} + +class _SplashOnboardingScreenState extends State { + bool _isCheckingAuth = true; + + @override + void initState() { + super.initState(); + _checkExistingSession(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + precacheImage(const AssetImage('assets/images/eva_logo.png'), context); + } + + Future _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 /auth/me + try { + if (token != null && token.isNotEmpty) { + final headers = await AppConfig.getAuthHeaders(); + final res = await http + .get( + Uri.parse('${AppConfig.baseUrl}/auth/me'), + headers: headers, + ) + .timeout(const Duration(seconds: 4)); + + if (res.statusCode == 200) { + final data = jsonDecode(res.body) as Map; + final name = data['name']?.toString(); + final email = data['email']?.toString(); + if (name != null || email != null) { + await AppConfig.setUserInfo( + name: name ?? 'Sahabat EVA', + email: 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/screens/support_emergency_screen.dart b/apps/eva_mobile/lib/screens/support_emergency_screen.dart new file mode 100644 index 0000000..0d35edc --- /dev/null +++ b/apps/eva_mobile/lib/screens/support_emergency_screen.dart @@ -0,0 +1,892 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +import '../config/app_config.dart'; +import '../core/routes/app_route_transitions.dart'; +import '../theme/app_colors.dart'; +import '../widgets/staggered_entrance.dart'; + +class SupportEmergencyScreen extends StatefulWidget { + const SupportEmergencyScreen({super.key}); + + /// Custom Route with smooth fade & gentle slide-up animation for enter and exit + static Route route() { + return AppRouteTransitions.modalSlideUpRoute( + page: const SupportEmergencyScreen(), + ); + } + + @override + State createState() => _SupportEmergencyScreenState(); +} + +class _SupportEmergencyScreenState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _pulseController; + late final Animation _pulseScale; + + Map? _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( + begin: 0.94, + end: 1.06, + ).animate(CurvedAnimation(parent: _pulseController, curve: Curves.easeInOutSine)); + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + Future _fetchPrimaryEmergencyContact() async { + try { + final token = await AppConfig.getToken(); + final response = await http.get( + Uri.parse('${AppConfig.baseUrl}/users/me/emergency-contacts'), + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + ).timeout(const Duration(seconds: 4)); + + if (response.statusCode == 200 && mounted) { + final List data = jsonDecode(response.body); + if (data.isNotEmpty) { + final primary = data.firstWhere( + (c) => c['is_primary'] == true, + orElse: () => data.first, + ); + setState(() { + _primaryContact = primary as Map; + _isLoadingContact = false; + }); + return; + } + } + } catch (_) { + // Graceful fallback to default contacts + } + if (mounted) { + setState(() { + _isLoadingContact = false; + }); + } + } + + Future _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) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tidak dapat membuka WhatsApp Dinkes'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + } + + 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 _HotlineModal(), + ); + } + + 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), + ], + ), + ), + ); + } +} + +class _BreathingExerciseModal extends StatefulWidget { + const _BreathingExerciseModal(); + + @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), + ], + ), + ); + } +} + + + +// ----------------------------------------------------------------------------- +// Emergency Hotline Modal +// ----------------------------------------------------------------------------- +class _HotlineModal extends StatelessWidget { + const _HotlineModal(); + + void _copy(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$label ($text) disalin ke clipboard'), + behavior: SnackBarBehavior.floating, + 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/screens/terms_disclaimer_screen.dart b/apps/eva_mobile/lib/screens/terms_disclaimer_screen.dart new file mode 100644 index 0000000..e200d8c --- /dev/null +++ b/apps/eva_mobile/lib/screens/terms_disclaimer_screen.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../theme/app_colors.dart'; +import '../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: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: AppColors.textPrimary, size: 20), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + 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 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/screens/voice_call_screen.dart b/apps/eva_mobile/lib/screens/voice_call_screen.dart new file mode 100644 index 0000000..f98ce1d --- /dev/null +++ b/apps/eva_mobile/lib/screens/voice_call_screen.dart @@ -0,0 +1,1649 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../features/voice_call/domain/entities/call_state.dart'; +import '../features/voice_call/presentation/controllers/ai_call_controller.dart'; +import '../features/voice_call/presentation/controllers/ai_call_state.dart'; +import '../features/voice_call/presentation/providers/ai_call_provider.dart'; +import '../services/daily_progress_local_service.dart'; +import '../theme/app_colors.dart'; +import '../widgets/custom_button.dart'; +import '../widgets/glass_card.dart'; +import 'support_emergency_screen.dart'; + +class VoiceCallScreen extends ConsumerStatefulWidget { + const VoiceCallScreen({super.key}); + + @override + ConsumerState createState() => _VoiceCallScreenState(); +} + +class _VoiceCallScreenState extends ConsumerState + 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); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return 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( + text: 'Lihat Detail Jurnal Refleksi', + isOutline: hasDassTriggered, + onPressed: () { + Navigator.pop(context); // Close Bottom Sheet + Navigator.pushNamedAndRemoveUntil( + context, + '/home', + (route) => false, + arguments: {'initialIndex': 1}, + ); + }, + ), + const SizedBox(height: 6), + Center( + child: TextButton( + onPressed: () { + Navigator.pop(context); + Navigator.pushNamedAndRemoveUntil( + context, + '/home', + (route) => false, + ); + }, + child: Text( + 'Kembali ke Beranda', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + 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: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + 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, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); +} +} + +/// Unified 5-node interactive visualizer built entirely in pure Flutter code. +/// +/// Behaviors: +/// 1. IDLE / LISTENING: 5 flat aligned dots. +/// 2. USER SPEAKING / AI SPEAKING: 5 vertical music bars reacting to speech intonation. +/// 3. THINKING (Morphing Animation): +/// - Dots merge & curve into a rotating circular orbit. +/// - Orbit expands outwards (radius 0px -> 26px). +/// - Colors transition smoothly to glowing iridescent aurora (Cyan -> Violet -> Pink). +/// - Dots rotate with gentle breathing harmonic wave. +/// - When thinking finishes, orbit shrinks back and aligns seamlessly into line dots/bars. +class _MorphingVoiceNodesVisualizer extends StatefulWidget { + final CallState callState; + final double soundLevel; + final bool isAiSpeaking; + final double animValue; + final bool isMuted; + + const _MorphingVoiceNodesVisualizer({ + 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 _morphAnimation; + + static const double _dotSize = 5.0; + static const List _minHeights = [8.0, 12.0, 16.0, 12.0, 8.0]; + static const List _maxHeights = [26.0, 42.0, 58.0, 42.0, 26.0]; + + final List _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.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 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 (Luna Tertiary) + // Node 1 (Mid-Left): Electric Periwinkle (Luna 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); // Luna 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(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 = []; + final innerEchoPoints = []; + + 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/screens/voice_session_detail_screen.dart b/apps/eva_mobile/lib/screens/voice_session_detail_screen.dart new file mode 100644 index 0000000..6200115 --- /dev/null +++ b/apps/eva_mobile/lib/screens/voice_session_detail_screen.dart @@ -0,0 +1,758 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../core/config/app_config.dart'; +import '../data/repositories/voice_call_repository.dart'; +import '../features/diary/data/datasources/mock_diary_datasource.dart'; +import '../features/diary/data/datasources/remote_diary_datasource.dart'; +import '../features/diary/data/models/diary_entry_model.dart'; +import '../theme/app_colors.dart'; +import '../widgets/glass_card.dart'; +import '../widgets/staggered_entrance.dart'; + +class VoiceSessionDetailScreen extends StatefulWidget { + final Map? sessionData; + + const VoiceSessionDetailScreen({ + super.key, + this.sessionData, + }); + + @override + State createState() => _VoiceSessionDetailScreenState(); +} + +class _VoiceSessionDetailScreenState extends State { + final VoiceCallRepository _voiceCallRepo = VoiceCallRepository(); + Map? _sessionData; + Timer? _pollingTimer; + int _pollCount = 0; + int _infiniteLimit = 10; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _sessionData = widget.sessionData != null ? Map.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 _pollUpdatedSessionData() async { + final sessionId = _sessionData?['id']?.toString(); + if (sessionId != null && sessionId.isNotEmpty) { + final updated = await _voiceCallRepo.fetchConversationById(sessionId); + if (updated != null && mounted) { + final newTitle = updated['title']?.toString() ?? ''; + setState(() { + _sessionData?['title'] = newTitle; + _sessionData?['is_title_generating'] = updated['is_title_generating']; + }); + + if (!_isTitleLoading(newTitle)) { + _pollingTimer?.cancel(); + _pollingTimer = null; + } + return; + } + } + + final res = await _voiceCallRepo.fetchTodayVoiceSessions(page: 1, limit: 10); + final items = (res['items'] as List?)?.cast>() ?? []; + if (items.isNotEmpty && mounted) { + final match = items.firstWhere( + (it) => it['id']?.toString() == sessionId, + orElse: () => items.first, + ); + final newTitle = match['title']?.toString() ?? ''; + setState(() { + _sessionData = Map.from(match); + }); + 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?; + 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.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.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: [ + IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary, size: 22), + onPressed: () => Navigator.pop(context), + ), + const SizedBox(width: 4), + 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(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(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 = AppConfig.useMockData + ? MockDiaryDataSource() + : RemoteDiaryDataSource(); + DiaryEntryModel? entry = await diaryRepo.getTodayDiary(); + entry ??= await diaryRepo.generateTodayDiary(); + + if (context.mounted) { + final Map 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/services/audio_recorder_service.dart b/apps/eva_mobile/lib/services/audio_recorder_service.dart new file mode 100644 index 0000000..5d97dee --- /dev/null +++ b/apps/eva_mobile/lib/services/audio_recorder_service.dart @@ -0,0 +1,47 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:typed_data'; + +/// [DEPRECATED in Hybrid Half-Duplex Mode] +/// +/// Under the Hybrid Half-Duplex architecture, raw microphone audio bytes +/// are NOT streamed to the backend to avoid audio hardware contention on +/// Android (which cannot reliably share the microphone hardware between raw +/// recorder and speech_to_text). STT transcription is computed locally on +/// client, and only textual transcript events and turn metadata are transmitted. +class AudioRecorderService { + bool _isRecording = false; + Timer? _recordSimulationTimer; + + final StreamController _audioStreamController = + StreamController.broadcast(); + + Stream get audioStream => _audioStreamController.stream; + bool get isRecording => _isRecording; + + void startRecording() { + if (_isRecording) return; + _isRecording = true; + + final random = Random(); + // Simulate streaming 160-byte audio PCM chunks every 100ms + _recordSimulationTimer = Timer.periodic(const Duration(milliseconds: 100), (_) { + if (_isRecording) { + final dummyPcmChunk = Uint8List.fromList( + List.generate(160, (_) => random.nextInt(256)), + ); + _audioStreamController.add(dummyPcmChunk); + } + }); + } + + void stopRecording() { + _isRecording = false; + _recordSimulationTimer?.cancel(); + } + + void dispose() { + stopRecording(); + _audioStreamController.close(); + } +} diff --git a/apps/eva_mobile/lib/services/daily_progress_local_service.dart b/apps/eva_mobile/lib/services/daily_progress_local_service.dart new file mode 100644 index 0000000..11fddee --- /dev/null +++ b/apps/eva_mobile/lib/services/daily_progress_local_service.dart @@ -0,0 +1,186 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Data model representing the user's daily self-care progress. +class DailyProgressData { + final String date; + final bool hasConversation; + final bool hasDiary; + final Set completedActivityIds; + + const DailyProgressData({ + required this.date, + this.hasConversation = false, + this.hasDiary = false, + this.completedActivityIds = const {}, + }); + + int get completedPillarsCount { + int count = 0; + if (hasConversation) count++; + if (hasDiary) count++; + if (completedActivityIds.isNotEmpty) count++; + return count; + } + + bool isActivityCompleted(String activityId) { + return completedActivityIds.contains(activityId); + } + + DailyProgressData copyWith({ + String? date, + bool? hasConversation, + bool? hasDiary, + Set? completedActivityIds, + }) { + return DailyProgressData( + date: date ?? this.date, + hasConversation: hasConversation ?? this.hasConversation, + hasDiary: hasDiary ?? this.hasDiary, + completedActivityIds: completedActivityIds ?? this.completedActivityIds, + ); + } + + Map toJson() { + return { + 'date': date, + 'has_conversation': hasConversation, + 'has_diary': hasDiary, + 'completed_activity_ids': completedActivityIds.toList(), + }; + } + + factory DailyProgressData.fromJson(Map json) { + return DailyProgressData( + date: json['date']?.toString() ?? '', + hasConversation: json['has_conversation'] == true, + hasDiary: json['has_diary'] == true, + completedActivityIds: (json['completed_activity_ids'] as List?) + ?.map((e) => e.toString()) + .toSet() ?? + {}, + ); + } +} + +/// Service to handle local persistence for daily self-care progress. +/// Automatically resets every new day using date-scoped keys (WIB UTC+7). +class DailyProgressLocalService { + static const String _keyPrefix = 'eva_daily_progress_'; + + static final StreamController _progressStreamController = + StreamController.broadcast(); + + /// Stream of daily progress updates emitted whenever progress changes. + static Stream get progressStream => + _progressStreamController.stream; + + /// Returns current WIB date string in format 'YYYY-MM-DD' + static String getTodayWibDate([DateTime? dateTime]) { + final now = dateTime ?? DateTime.now().toUtc().add(const Duration(hours: 7)); + final year = now.year.toString().padLeft(4, '0'); + final month = now.month.toString().padLeft(2, '0'); + final day = now.day.toString().padLeft(2, '0'); + return '$year-$month-$day'; + } + + static String _getStorageKey(String dateStr) { + return '$_keyPrefix$dateStr'; + } + + /// Load today's daily progress from local storage. + /// If today is a new day, returns a fresh progress state. + static Future 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'); + + if (raw == null || raw.isEmpty) { + final initial = DailyProgressData(date: todayStr); + _progressStreamController.add(initial); + return initial; + } + + try { + final decoded = jsonDecode(raw) as Map; + final loaded = DailyProgressData.fromJson(decoded); + _progressStreamController.add(loaded); + return loaded; + } catch (_) { + final fallback = DailyProgressData(date: todayStr); + _progressStreamController.add(fallback); + return fallback; + } + } + + /// Save daily progress to local storage and broadcast to all listeners. + static Future saveProgress(DailyProgressData data) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _getStorageKey(data.date), + jsonEncode(data.toJson()), + ); + _progressStreamController.add(data); + } + + /// Record conversation check-in for today. + static Future recordConversationCheckin({ + bool hasConversation = true, + DateTime? now, + }) async { + final current = await loadTodayProgress(now: now); + final updated = current.copyWith(hasConversation: hasConversation); + await saveProgress(updated); + return updated; + } + + /// Record diary check-in for today. + static Future recordDiaryCheckin({ + bool hasDiary = true, + DateTime? now, + }) async { + final current = await loadTodayProgress(now: now); + final updated = current.copyWith(hasDiary: hasDiary); + await saveProgress(updated); + return updated; + } + + /// Record completion status of an activity for today. + static Future recordActivityCompletion( + String activityId, + bool isCompleted, { + DateTime? now, + }) async { + final current = await loadTodayProgress(now: now); + final updatedIds = Set.from(current.completedActivityIds); + if (isCompleted) { + updatedIds.add(activityId); + } else { + updatedIds.remove(activityId); + } + final updated = current.copyWith(completedActivityIds: updatedIds); + await saveProgress(updated); + return updated; + } + + /// Toggle completion status of an activity for today. + static Future toggleActivityCompletion( + String activityId, { + DateTime? now, + }) async { + final current = await loadTodayProgress(now: now); + final isDone = current.isActivityCompleted(activityId); + return recordActivityCompletion(activityId, !isDone, now: now); + } + + /// Check if a specific activity is completed today. + static Future isActivityCompletedToday( + String activityId, { + DateTime? now, + }) async { + final current = await loadTodayProgress(now: now); + return current.isActivityCompleted(activityId); + } +} diff --git a/apps/eva_mobile/lib/services/stt_service.dart b/apps/eva_mobile/lib/services/stt_service.dart new file mode 100644 index 0000000..5be6949 --- /dev/null +++ b/apps/eva_mobile/lib/services/stt_service.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:speech_to_text/speech_to_text.dart'; + +/// Service managing real client-side Speech-To-Text transcription with tuned ChatGPT-style parameters. +class SttService { + final SpeechToText _speechToText = SpeechToText(); + bool _isInitialized = false; + bool _shouldKeepListening = false; + Timer? _restartTimer; + + final StreamController _transcriptController = + StreamController.broadcast(); + + Stream get transcriptStream => _transcriptController.stream; + bool get isListening => _speechToText.isListening; + bool get isInitialized => _isInitialized; + + String _resolvedLocaleId = 'id-ID'; + String get resolvedLocaleId => _resolvedLocaleId; + + Future initialize() async { + if (_isInitialized) return true; + try { + _isInitialized = await _speechToText.initialize( + onError: (errorNotification) { + debugPrint('âš ī¸ [STT ERROR]: ${errorNotification.errorMsg}'); + if (_shouldKeepListening) { + _scheduleAutoRestart(); + } + }, + onStatus: (status) { + debugPrint('đŸŽ™ī¸ [STT STATUS]: $status'); + if ((status == 'done' || status == 'notListening') && _shouldKeepListening) { + _scheduleAutoRestart(); + } + }, + ); + if (_isInitialized) { + try { + final locales = await _speechToText.locales(); + for (final loc in locales) { + final normalized = loc.localeId.toLowerCase().replaceAll('_', '-'); + if (normalized == 'id-id' || normalized == 'id') { + _resolvedLocaleId = loc.localeId; + break; + } + } + } catch (_) { + _resolvedLocaleId = 'id-ID'; + } + } + debugPrint('đŸŽ™ī¸ [STT INIT SUCCESS]: $_isInitialized (Locale: $_resolvedLocaleId)'); + } catch (e) { + debugPrint('❌ [STT INIT EXCEPTION]: $e'); + _isInitialized = false; + } + return _isInitialized; + } + + void _scheduleAutoRestart() { + _restartTimer?.cancel(); + if (!_shouldKeepListening) return; + _restartTimer = Timer(const Duration(milliseconds: 400), () { + if (_shouldKeepListening && !_speechToText.isListening) { + debugPrint('🔄 [STT AUTO RESTART]: Restarting speech recognizer listener...'); + startListening(); + } + }); + } + + Future startListening({void Function(String transcript)? onResult}) async { + _shouldKeepListening = true; + if (!_isInitialized) { + await initialize(); + } + if (!_isInitialized) { + debugPrint('âš ī¸ [STT SERVICE]: SpeechToText not available or mic permission denied.'); + return; + } + + if (_speechToText.isListening) return; + + try { + await _speechToText.listen( + onResult: (result) { + final recognizedWords = result.recognizedWords; + debugPrint('đŸŽ™ī¸ [REAL STT RECOGNIZED]: "$recognizedWords" (final: ${result.finalResult})'); + if (recognizedWords.isNotEmpty) { + emitTranscript(recognizedWords); + if (onResult != null) { + onResult(recognizedWords); + } + } + }, + localeId: _resolvedLocaleId, + listenOptions: SpeechListenOptions( + localeId: _resolvedLocaleId, + listenFor: const Duration(seconds: 60), + pauseFor: const Duration(seconds: 5), + partialResults: true, + cancelOnError: false, + listenMode: ListenMode.dictation, + ), + ); + } catch (e) { + debugPrint('❌ [STT LISTEN EXCEPTION]: $e'); + if (_shouldKeepListening) { + _scheduleAutoRestart(); + } + } + } + + Future stopListening() async { + _shouldKeepListening = false; + _restartTimer?.cancel(); + if (_speechToText.isListening) { + try { + await _speechToText.stop(); + } catch (e) { + debugPrint('âš ī¸ [STT STOP EXCEPTION]: $e'); + } + } + } + + void emitTranscript(String text) { + if (text.isNotEmpty) { + _transcriptController.add(text); + } + } + + void dispose() { + stopListening(); + _transcriptController.close(); + } +} diff --git a/apps/eva_mobile/lib/services/vad_audio_service.dart b/apps/eva_mobile/lib/services/vad_audio_service.dart new file mode 100644 index 0000000..9203952 --- /dev/null +++ b/apps/eva_mobile/lib/services/vad_audio_service.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:speech_to_text/speech_to_text.dart' as stt; + +/// Representation of Voice Activity Detection (VAD) States +enum VadState { + idle, + listening, + userSpeaking, + aiProcessing, + aiSpeaking, + bargeInInterrupted, +} + +/// [DEPRECATED in Hybrid Half-Duplex Mode] +/// +/// NOTE: In the Hybrid Half-Duplex architecture, authoritative turn detection, +/// silence endpointing, and phrase completion are handled by the backend +/// (TurnAggregator & TurnDetectionService). +/// +/// Frontend microphone capture is exclusively handled by [SpeechRecognitionService], +/// which prevents microphone contention on Android OS. Simulated VAD timers and +/// client-side silence end-of-turn detectors are disabled in favor of adaptive +/// server endpointing. +class VadAudioService { + static final VadAudioService _instance = VadAudioService._internal(); + factory VadAudioService() => _instance; + VadAudioService._internal(); + + VadState _currentState = VadState.idle; + bool _isMuted = false; + final double _speechThresholdDb = 0.35; + final int _silenceDurationMs = 1000; + + final StreamController _vadStateController = + StreamController.broadcast(); + final StreamController _amplitudeController = + StreamController.broadcast(); + final StreamController _transcriptController = + StreamController.broadcast(); + + final stt.SpeechToText _speech = stt.SpeechToText(); + bool _isSpeechInitialized = false; + + Timer? _amplitudeSimTimer; + Timer? _silenceTimer; + + // Getters + VadState get currentState => _currentState; + bool get isMuted => _isMuted; + double get speechThresholdDb => _speechThresholdDb; + int get silenceDurationMs => _silenceDurationMs; + + Stream get vadStateStream => _vadStateController.stream; + Stream get audioAmplitudeStream => _amplitudeController.stream; + Stream get transcriptStream => _transcriptController.stream; + + /// Initialize real mic listening or fallback to simulation + Future startListening() async { + _currentState = VadState.listening; + _vadStateController.add(_currentState); + + try { + if (!_isSpeechInitialized) { + _isSpeechInitialized = await _speech.initialize( + onStatus: (status) { + if (status == 'listening') { + _setState(VadState.listening); + } else if (status == 'notListening') { + if (_currentState == VadState.userSpeaking) { + _setState(VadState.aiProcessing); + } + } + }, + onError: (_) { + // In case of error, start simulation fallback + _startAmplitudeSimulation(); + }, + ); + } + + if (_isSpeechInitialized) { + await _speech.listen( + localeId: 'id-ID', + listenFor: const Duration(seconds: 60), + pauseFor: const Duration(milliseconds: 2500), + listenOptions: stt.SpeechListenOptions( + localeId: 'id-ID', + listenMode: stt.ListenMode.dictation, + partialResults: true, + listenFor: const Duration(seconds: 60), + pauseFor: const Duration(milliseconds: 2500), + ), + onResult: (result) { + if (result.recognizedWords.isNotEmpty) { + _setState(VadState.userSpeaking); + if (result.finalResult) { + _transcriptController.add(result.recognizedWords); + _setState(VadState.aiProcessing); + } + } + }, + onSoundLevelChange: (level) { + if (!_isMuted) { + // Normalize level (typically -10 to 10 or 0 to 100) + final normalized = ((level + 10) / 30.0).clamp(0.05, 1.0); + _amplitudeController.add(normalized); + } + }, + ); + } else { + _startAmplitudeSimulation(); + } + } catch (_) { + _startAmplitudeSimulation(); + } + } + + /// Stop listening + void stopListening() { + _amplitudeSimTimer?.cancel(); + _silenceTimer?.cancel(); + try { + if (_speech.isListening) { + _speech.stop(); + } + } catch (_) {} + + _currentState = VadState.idle; + _vadStateController.add(_currentState); + _amplitudeController.add(0.0); + } + + /// Toggle microphone mute + bool toggleMute() { + _isMuted = !_isMuted; + if (_isMuted) { + _amplitudeController.add(0.0); + try { + if (_speech.isListening) _speech.stop(); + } catch (_) {} + } else { + if (_currentState == VadState.listening || _currentState == VadState.userSpeaking) { + startListening(); + } + } + return _isMuted; + } + + void _setState(VadState state) { + if (_currentState != state) { + _currentState = state; + _vadStateController.add(_currentState); + } + } + + void setAiSpeaking() { + _setState(VadState.aiSpeaking); + } + + void setAiThinking() { + _setState(VadState.aiProcessing); + } + + void setIdle() { + _setState(VadState.idle); + } + + void setListening() { + _setState(VadState.listening); + } + + /// Simulate Speech Start (for tap button or manual trigger) + void triggerSpeechStart() { + if (_isMuted) return; + _silenceTimer?.cancel(); + + if (_currentState == VadState.aiSpeaking) { + _setState(VadState.bargeInInterrupted); + Timer(const Duration(milliseconds: 400), () { + _setState(VadState.userSpeaking); + }); + } else { + _setState(VadState.userSpeaking); + } + } + + /// Simulate Speech Pause + void triggerSpeechPause([String? text]) { + if (_currentState != VadState.userSpeaking) return; + _silenceTimer?.cancel(); + _silenceTimer = Timer(Duration(milliseconds: _silenceDurationMs), () { + _setState(VadState.aiProcessing); + _amplitudeController.add(0.0); + _transcriptController.add( + text ?? 'Halo EVA, aku ingin bercerita tentang apa yang aku rasakan hari ini.', + ); + }); + } + + void _startAmplitudeSimulation() { + _amplitudeSimTimer?.cancel(); + final random = Random(); + _amplitudeSimTimer = + Timer.periodic(const Duration(milliseconds: 80), (timer) { + if (_isMuted) return; + double amplitude = 0.05; + if (_currentState == VadState.userSpeaking) { + amplitude = 0.45 + (random.nextDouble() * 0.50); + } else if (_currentState == VadState.aiSpeaking) { + amplitude = 0.35 + (random.nextDouble() * 0.45); + } else if (_currentState == VadState.listening) { + amplitude = 0.05 + (random.nextDouble() * 0.12); + } + _amplitudeController.add(amplitude); + }); + } +} diff --git a/apps/eva_mobile/lib/theme/app_colors.dart b/apps/eva_mobile/lib/theme/app_colors.dart new file mode 100644 index 0000000..b81e6ba --- /dev/null +++ b/apps/eva_mobile/lib/theme/app_colors.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class AppColors { + + static const Color primary = Color(0xFF5358CB); // Periwinkle Primary + + static const Color primaryLight = Color(0xFF8B93FF); // Primary Highlight + + static const Color primaryContainer = Color(0xFFE8E9FF); // Soft Primary Container + + + static const Color secondary = Color(0xFFE2DAFF); // Lavender Secondary + + static const Color secondaryDark = Color(0xFF605A79); // Lavender Dark + + + static const Color tertiary = Color(0xFFA7E6FF); // Sky Blue Tertiary + + static const Color tertiaryDark = Color(0xFF20667B); // Sky Blue Dark + + + static const Color background = Color(0xFFF8F9FE); // Cool Tint Off-White + + static const Color surface = Color(0xFFFFFFFF); + + + static const Color textPrimary = Color(0xFF191C1F); + + static const Color textSecondary = Color(0xFF767684); + + static const Color textLight = Color(0xFF9EA0AB); + + + static const Color glassBackground = Color(0xCCFFFFFF); // 80% opacity white + + static const Color glassBorder = Color(0x66FFFFFF); // 40% opacity white + + static const Color glassShadow = Color(0x145358CB); // 8% periwinkle shadow + + static const Color divider = Color(0x0E5358CB); // Soft gentle divider (5.5% periwinkle) + + + static const Color success = Color(0xFF4CAF50); + + static const Color warning = Color(0xFFFF9800); + + static const Color error = Color(0xFFBA1A1A); + +} + diff --git a/apps/eva_mobile/lib/theme/app_theme.dart b/apps/eva_mobile/lib/theme/app_theme.dart new file mode 100644 index 0000000..836b8f6 --- /dev/null +++ b/apps/eva_mobile/lib/theme/app_theme.dart @@ -0,0 +1,57 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import 'app_colors.dart'; + + + +class AppTheme { + + static ThemeData get lightTheme { + + return ThemeData( + + useMaterial3: true, + + scaffoldBackgroundColor: AppColors.background, + + colorScheme: const ColorScheme.light( + + primary: AppColors.primary, + + primaryContainer: AppColors.primaryContainer, + + secondary: AppColors.secondary, + + tertiary: AppColors.tertiary, + + surface: AppColors.surface, + + ), + + appBarTheme: const AppBarTheme( + + backgroundColor: Colors.transparent, + + elevation: 0, + + scrolledUnderElevation: 0, + + iconTheme: IconThemeData(color: AppColors.textPrimary), + + ), + + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: ZoomPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.windows: ZoomPageTransitionsBuilder(), + }, + ), + + ); + + } + +} + diff --git a/apps/eva_mobile/lib/theme/app_typography.dart b/apps/eva_mobile/lib/theme/app_typography.dart new file mode 100644 index 0000000..9830dbc --- /dev/null +++ b/apps/eva_mobile/lib/theme/app_typography.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +import 'package:google_fonts/google_fonts.dart'; + +import 'app_colors.dart'; + + + +class AppTypography { + + static TextStyle displayLarge = GoogleFonts.inter( + + fontSize: 28, + + fontWeight: FontWeight.w700, + + color: AppColors.textPrimary, + + height: 1.3, + + ); + + + + static TextStyle headlineMedium = GoogleFonts.inter( + + fontSize: 22, + + fontWeight: FontWeight.w700, + + color: AppColors.textPrimary, + + height: 1.3, + + ); + + + + static TextStyle titleMedium = GoogleFonts.inter( + + fontSize: 16, + + fontWeight: FontWeight.w600, + + color: AppColors.textPrimary, + + height: 1.4, + + ); + + + + static TextStyle bodyLarge = GoogleFonts.inter( + + fontSize: 15, + + fontWeight: FontWeight.w400, + + color: AppColors.textSecondary, + + height: 1.5, + + ); + + + + static TextStyle bodyMedium = GoogleFonts.inter( + + fontSize: 14, + + fontWeight: FontWeight.w400, + + color: AppColors.textSecondary, + + height: 1.5, + + ); + + + + static TextStyle labelSmall = GoogleFonts.inter( + + fontSize: 12, + + fontWeight: FontWeight.w600, + + color: AppColors.textLight, + + letterSpacing: 0.5, + + ); + + + + static TextStyle buttonText = GoogleFonts.inter( + + fontSize: 16, + + fontWeight: FontWeight.w600, + + color: Colors.white, + + ); + +} + diff --git a/apps/eva_mobile/lib/widgets/custom_button.dart b/apps/eva_mobile/lib/widgets/custom_button.dart new file mode 100644 index 0000000..b866886 --- /dev/null +++ b/apps/eva_mobile/lib/widgets/custom_button.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; + +import 'package:google_fonts/google_fonts.dart'; + +import '../theme/app_colors.dart'; + + + +class CustomPillButton extends StatelessWidget { + + final String text; + + final VoidCallback onPressed; + + final bool isOutline; + + final IconData? suffixIcon; + + final double height; + + + + const CustomPillButton({ + + super.key, + + required this.text, + + required this.onPressed, + + this.isOutline = false, + + this.suffixIcon, + + this.height = 54.0, + + }); + + + + @override + + Widget build(BuildContext context) { + + return SizedBox( + + width: double.infinity, + + height: height, + + child: ElevatedButton( + + onPressed: onPressed, + + style: ElevatedButton.styleFrom( + + backgroundColor: isOutline ? Colors.transparent : AppColors.primary, + + foregroundColor: isOutline ? AppColors.primary : Colors.white, + + elevation: isOutline ? 0 : 4, + + shadowColor: AppColors.primary.withValues(alpha: 0.3), + + padding: const EdgeInsets.symmetric(horizontal: 12), + + shape: RoundedRectangleBorder( + + borderRadius: BorderRadius.circular(999), + + side: isOutline + + ? const BorderSide(color: AppColors.primary, width: 1.5) + + : BorderSide.none, + + ), + + ), + + child: Row( + + mainAxisAlignment: MainAxisAlignment.center, + + children: [ + + Flexible( + + child: Text( + + text, + + maxLines: 1, + + overflow: TextOverflow.ellipsis, + + textAlign: TextAlign.center, + + style: GoogleFonts.inter( + + fontSize: 14, + + fontWeight: FontWeight.w600, + + color: isOutline ? AppColors.primary : Colors.white, + + ), + + ), + + ), + + if (suffixIcon != null) ...[ + + const SizedBox(width: 8), + + Icon( + + suffixIcon, + + size: 20, + + color: isOutline ? AppColors.primary : Colors.white, + + ), + + ], + + ], + + ), + + ), + + ); + + } + +} + + + +class SocialPillButton extends StatelessWidget { + + final String type; // 'google' or 'apple' + + final VoidCallback onPressed; + + + + const SocialPillButton({ + + super.key, + + required this.type, + + required this.onPressed, + + }); + + + + @override + + Widget build(BuildContext context) { + + final isGoogle = type == 'google'; + + final label = isGoogle ? 'Google' : 'Apple'; + + final iconData = isGoogle ? Icons.g_mobiledata : Icons.apple; + + + + return SizedBox( + + height: 50, + + child: OutlinedButton( + + onPressed: onPressed, + + style: OutlinedButton.styleFrom( + + backgroundColor: Colors.white, + + side: const BorderSide(color: Color(0xFFE2E8F0), width: 1.5), + + shape: RoundedRectangleBorder( + + borderRadius: BorderRadius.circular(999), + + ), + + ), + + child: Row( + + mainAxisAlignment: MainAxisAlignment.center, + + children: [ + + Icon(iconData, color: AppColors.textPrimary, size: isGoogle ? 28 : 22), + + const SizedBox(width: 6), + + Flexible( + + child: Text( + + label, + + maxLines: 1, + + overflow: TextOverflow.ellipsis, + + style: GoogleFonts.inter( + + fontSize: 14, + + fontWeight: FontWeight.w600, + + color: AppColors.textPrimary, + + ), + + ), + + ), + + ], + + ), + + ), + + ); + + } + +} + diff --git a/apps/eva_mobile/lib/widgets/custom_text_field.dart b/apps/eva_mobile/lib/widgets/custom_text_field.dart new file mode 100644 index 0000000..cd1b8ac --- /dev/null +++ b/apps/eva_mobile/lib/widgets/custom_text_field.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +import 'package:google_fonts/google_fonts.dart'; + +import '../theme/app_colors.dart'; + + + +class CustomPillTextField extends StatefulWidget { + final String hintText; + final IconData prefixIcon; + final bool isPassword; + final TextEditingController? controller; + final TextInputType keyboardType; + final Widget? suffixIcon; + final ValueChanged? onChanged; + + const CustomPillTextField({ + super.key, + required this.hintText, + required this.prefixIcon, + this.isPassword = false, + this.controller, + this.keyboardType = TextInputType.text, + this.suffixIcon, + this.onChanged, + }); + + + + @override + + State createState() => _CustomPillTextFieldState(); + +} + + + +class _CustomPillTextFieldState extends State { + + bool _obscureText = true; + + + + @override + + Widget build(BuildContext context) { + + return Container( + + decoration: BoxDecoration( + + color: Colors.white, + + borderRadius: BorderRadius.circular(999), + + border: Border.all(color: Colors.grey.shade300, width: 1.2), + + ), + + child: TextField( + controller: widget.controller, + obscureText: widget.isPassword ? _obscureText : false, + keyboardType: widget.keyboardType, + onChanged: widget.onChanged, + style: GoogleFonts.inter( + fontSize: 15, + color: AppColors.textPrimary, + ), + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: GoogleFonts.inter( + fontSize: 15, + color: AppColors.textLight, + ), + prefixIcon: Icon( + widget.prefixIcon, + color: AppColors.textSecondary, + size: 20, + ), + suffixIcon: widget.isPassword + ? IconButton( + icon: Icon( + _obscureText + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + color: AppColors.textSecondary, + size: 20, + ), + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + ) + : widget.suffixIcon, + + border: InputBorder.none, + + contentPadding: const EdgeInsets.symmetric( + + horizontal: 20, + + vertical: 16, + + ), + + ), + + ), + + ); + + } + +} + diff --git a/apps/eva_mobile/lib/widgets/emergency_contact_bottom_sheet.dart b/apps/eva_mobile/lib/widgets/emergency_contact_bottom_sheet.dart new file mode 100644 index 0000000..6de4b1e --- /dev/null +++ b/apps/eva_mobile/lib/widgets/emergency_contact_bottom_sheet.dart @@ -0,0 +1,561 @@ + +import 'package:flutter/material.dart'; + +import 'package:google_fonts/google_fonts.dart'; + +import '../theme/app_colors.dart'; + +import 'custom_button.dart'; + + + +class EmergencyContactBottomSheet extends StatefulWidget { + + final String initialName; + + final String initialRelation; + + final String initialPhone; + + final Function(String name, String relation, String phone) onSave; + + + + const EmergencyContactBottomSheet({ + + super.key, + + required this.initialName, + + required this.initialRelation, + + required this.initialPhone, + + required this.onSave, + + }); + + + + @override + + State createState() => _EmergencyContactBottomSheetState(); + +} + + + +class _EmergencyContactBottomSheetState extends State { + + late TextEditingController _nameController; + + late TextEditingController _relationController; + + late TextEditingController _phoneController; + + late TextEditingController _secondaryPhoneController; + + + + @override + + void initState() { + + super.initState(); + + _nameController = TextEditingController(text: widget.initialName); + + _relationController = TextEditingController(text: widget.initialRelation); + + _phoneController = TextEditingController(text: widget.initialPhone); + + _secondaryPhoneController = TextEditingController(text: '+62 857-1122-3344'); + + } + + + + @override + + void dispose() { + + _nameController.dispose(); + + _relationController.dispose(); + + _phoneController.dispose(); + + _secondaryPhoneController.dispose(); + + super.dispose(); + + } + + + + @override + + Widget build(BuildContext context) { + + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + + + + return Container( + padding: EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 24.0 + bottomInset), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.98), + borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), + border: Border.all( + color: Colors.white.withValues(alpha: 0.8), + width: 1.5, + ), + ), + + child: SingleChildScrollView( + + child: Column( + + mainAxisSize: MainAxisSize.min, + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + // Handle Bar + + Center( + + child: Container( + + width: 48, + + height: 5, + + decoration: BoxDecoration( + + color: Colors.grey.shade300, + + borderRadius: BorderRadius.circular(999), + + ), + + ), + + ), + + const SizedBox(height: 20), + + + + // Header Title + + Row( + + children: [ + + Container( + + width: 42, + + height: 42, + + decoration: BoxDecoration( + + color: const Color(0xFFFFDCDD), + + borderRadius: BorderRadius.circular(14), + + ), + + child: const Icon( + + Icons.contact_phone_outlined, + + color: Color(0xFFD32F2F), + + size: 22, + + ), + + ), + + const SizedBox(width: 14), + + Expanded( + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + Text( + + 'Kontak Darurat Utama', + + style: GoogleFonts.inter( + + fontSize: 18, + + fontWeight: FontWeight.w800, + + color: AppColors.textPrimary, + + ), + + ), + + Text( + + 'Orang terdekat yang akan dihubungi saat situasi krisis.', + + style: GoogleFonts.inter( + + fontSize: 12, + + color: AppColors.textSecondary, + + ), + + ), + + ], + + ), + + ), + + ], + + ), + + const SizedBox(height: 24), + + + + // Field 1: Nama Lengkap Kontak Utama + + Text( + + 'NAMA KONTAK UTAMA', + + style: GoogleFonts.inter( + + fontSize: 10, + + fontWeight: FontWeight.w700, + + color: AppColors.textLight, + + letterSpacing: 0.8, + + ), + + ), + + const SizedBox(height: 6), + + TextField( + + controller: _nameController, + + style: GoogleFonts.inter(fontSize: 14, color: AppColors.textPrimary), + + decoration: InputDecoration( + + hintText: 'Nama lengkap kontak...', + + filled: true, + + fillColor: const Color(0xFFF4F6FB), + + border: OutlineInputBorder( + + borderRadius: BorderRadius.circular(14), + + borderSide: BorderSide.none, + + ), + + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + + ), + + ), + + const SizedBox(height: 16), + + + + // Field 2 & 3: Hubungan & Nomor Telepon + + Row( + + children: [ + + Expanded( + + flex: 4, + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + Text( + + 'HUBUNGAN', + + style: GoogleFonts.inter( + + fontSize: 10, + + fontWeight: FontWeight.w700, + + color: AppColors.textLight, + + letterSpacing: 0.8, + + ), + + ), + + const SizedBox(height: 6), + + TextField( + + controller: _relationController, + + style: GoogleFonts.inter(fontSize: 14, color: AppColors.textPrimary), + + decoration: InputDecoration( + + hintText: 'Ibu / Sahabat...', + + filled: true, + + fillColor: const Color(0xFFF4F6FB), + + border: OutlineInputBorder( + + borderRadius: BorderRadius.circular(14), + + borderSide: BorderSide.none, + + ), + + contentPadding: + + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + + ), + + ), + + ], + + ), + + ), + + const SizedBox(width: 12), + + Expanded( + + flex: 6, + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + Text( + + 'NOMOR HANDPHONE', + + style: GoogleFonts.inter( + + fontSize: 10, + + fontWeight: FontWeight.w700, + + color: AppColors.textLight, + + letterSpacing: 0.8, + + ), + + ), + + const SizedBox(height: 6), + + TextField( + + controller: _phoneController, + + keyboardType: TextInputType.phone, + + style: GoogleFonts.inter(fontSize: 14, color: AppColors.textPrimary), + + decoration: InputDecoration( + + hintText: '+62 812-xxxx...', + + filled: true, + + fillColor: const Color(0xFFF4F6FB), + + border: OutlineInputBorder( + + borderRadius: BorderRadius.circular(14), + + borderSide: BorderSide.none, + + ), + + contentPadding: + + const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + + ), + + ), + + ], + + ), + + ), + + ], + + ), + + const SizedBox(height: 16), + + + + // Field 4: Nomor Kontak Sekunder + + Text( + + 'KONTAK SEKUNDER (OPSIONAL)', + + style: GoogleFonts.inter( + + fontSize: 10, + + fontWeight: FontWeight.w700, + + color: AppColors.textLight, + + letterSpacing: 0.8, + + ), + + ), + + const SizedBox(height: 6), + + TextField( + + controller: _secondaryPhoneController, + + keyboardType: TextInputType.phone, + + style: GoogleFonts.inter(fontSize: 14, color: AppColors.textPrimary), + + decoration: InputDecoration( + + hintText: 'Nomor telepon cadangan...', + + filled: true, + + fillColor: const Color(0xFFF4F6FB), + + border: OutlineInputBorder( + + borderRadius: BorderRadius.circular(14), + + borderSide: BorderSide.none, + + ), + + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + + ), + + ), + + const SizedBox(height: 24), + + + + // Action Buttons: Simpan & Uji Panggilan Darurat + + CustomPillButton( + + text: 'Simpan Perubahan Kontak', + + onPressed: () { + + widget.onSave( + + _nameController.text.trim(), + + _relationController.text.trim(), + + _phoneController.text.trim(), + + ); + + Navigator.pop(context); + + }, + + ), + + const SizedBox(height: 10), + + CustomPillButton( + + text: 'Uji Panggilan Darurat', + + isOutline: true, + + onPressed: () { + + ScaffoldMessenger.of(context).showSnackBar( + + SnackBar( + + content: Text( + + 'Menghubungi ${_nameController.text.trim()} (${_phoneController.text.trim()})...', + + ), + + backgroundColor: const Color(0xFFD32F2F), + + ), + + ); + + }, + + ), + + const SizedBox(height: 12), + + ], + + ), + ), + ); + } + +} + diff --git a/apps/eva_mobile/lib/widgets/floating_nav_bar.dart b/apps/eva_mobile/lib/widgets/floating_nav_bar.dart new file mode 100644 index 0000000..ab5c1dd --- /dev/null +++ b/apps/eva_mobile/lib/widgets/floating_nav_bar.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../theme/app_colors.dart'; + +class FloatingNavBar extends StatelessWidget { + final int currentIndex; + final Function(int) onTap; + + const FloatingNavBar({ + super.key, + required this.currentIndex, + required this.onTap, + }); + + /// Map page currentIndex (0..3) to 5-slot layout column index: + /// Slot 0: Home (index 0) + /// Slot 1: Diary (index 1) + /// Slot 2: Placeholder for Center FAB + /// Slot 3: Monitoring / Tren (index 2) + /// Slot 4: Profile (index 3) + int _getSlotForIndex(int index) { + switch (index) { + case 0: + return 0; + case 1: + return 1; + case 2: + return 3; + case 3: + return 4; + default: + return 0; + } + } + + @override + Widget build(BuildContext context) { + final activeSlot = _getSlotForIndex(currentIndex); + + return Stack( + alignment: Alignment.bottomCenter, + clipBehavior: Clip.none, + children: [ + // Main Floating Nav Bar Container + Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), + height: 68, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.12), + blurRadius: 25, + spreadRadius: 0, + offset: const Offset(0, 8), + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + spreadRadius: 0, + offset: const Offset(0, 2), + ), + ], + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: Colors.white.withValues(alpha: 0.8), + width: 1.5, + ), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final slotWidth = constraints.maxWidth / 5.0; + final pillWidth = slotWidth - 8.0; + final pillHeight = 52.0; + final pillTop = (constraints.maxHeight - pillHeight) / 2.0; + final pillLeft = (activeSlot * slotWidth) + 4.0; + + return Stack( + children: [ + // Animated Sliding Pill Indicator + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + left: pillLeft, + top: pillTop, + width: pillWidth, + height: pillHeight, + child: Container( + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + ), + ), + + // Navigation Icons & Labels Row + Positioned.fill( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Slot 0: Beranda (Home) + Expanded( + flex: 1, + child: _buildSideNavItem( + index: 0, + iconUnselected: Icons.home_outlined, + iconSelected: Icons.home, + label: 'Beranda', + ), + ), + + // Slot 1: Jurnal (Diary) + Expanded( + flex: 1, + child: _buildSideNavItem( + index: 1, + iconUnselected: Icons.menu_book_outlined, + iconSelected: Icons.menu_book, + label: 'Jurnal', + ), + ), + + // Slot 2: Placeholder Space for Center FAB + const Expanded( + flex: 1, + child: SizedBox(), + ), + + // Slot 3: Tren (Monitoring) + Expanded( + flex: 1, + child: _buildSideNavItem( + index: 2, + iconUnselected: Icons.show_chart_outlined, + iconSelected: Icons.show_chart, + label: 'Tren', + ), + ), + + // Slot 4: Profil (Profile) + Expanded( + flex: 1, + child: _buildSideNavItem( + index: 3, + iconUnselected: Icons.person_outline, + iconSelected: Icons.person, + label: 'Profil', + ), + ), + ], + ), + ), + ], + ); + }, + ), + ), + ), + + // Central Floating Circular Chat Action Button (100% Mathematically Centered) + Positioned( + bottom: 30, + child: GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/chat'); + }, + child: Container( + width: 58, + height: 58, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [ + Color(0xFF6C63FF), + Color(0xFF5358CB), + Color(0xFF3B3E99), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5358CB).withValues(alpha: 0.45), + blurRadius: 20, + spreadRadius: 2, + offset: const Offset(0, 6), + ), + ], + border: Border.all( + color: Colors.white, + width: 3.0, + ), + ), + child: const Icon( + Icons.auto_awesome, + color: Colors.white, + size: 25, + ), + ), + ), + ), + ], + ); + } + + Widget _buildSideNavItem({ + required int index, + required IconData iconUnselected, + required IconData iconSelected, + required String label, + }) { + final isSelected = currentIndex == index; + + return GestureDetector( + onTap: () => onTap(index), + behavior: HitTestBehavior.opaque, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TweenAnimationBuilder( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + tween: ColorTween( + begin: isSelected ? AppColors.textLight : AppColors.primary, + end: isSelected ? AppColors.primary : AppColors.textLight, + ), + builder: (context, color, child) { + return Icon( + isSelected ? iconSelected : iconUnselected, + size: 22, + color: color, + ); + }, + ), + const SizedBox(height: 3), + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? AppColors.primary : AppColors.textLight, + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/widgets/glass_card.dart b/apps/eva_mobile/lib/widgets/glass_card.dart new file mode 100644 index 0000000..f6672c4 --- /dev/null +++ b/apps/eva_mobile/lib/widgets/glass_card.dart @@ -0,0 +1,118 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + + + +class GlassCard extends StatelessWidget { + + final Widget child; + + final EdgeInsetsGeometry? padding; + + final EdgeInsetsGeometry? margin; + + final double borderRadius; + + final Color? backgroundColor; + + final Color? borderColor; + + final double? width; + + final double? height; + + final VoidCallback? onTap; + final bool enableBlur; + + const GlassCard({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(20), + this.margin, + this.borderRadius = 24.0, + this.backgroundColor, + this.borderColor, + this.width, + this.height, + this.onTap, + this.enableBlur = false, + }); + + @override + Widget build(BuildContext context) { + final cardDecoration = BoxDecoration( + color: backgroundColor ?? AppColors.glassBackground, + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all( + color: borderColor ?? AppColors.glassBorder, + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: AppColors.glassShadow, + blurRadius: 30, + spreadRadius: 0, + offset: const Offset(0, 10), + ), + ], + ); + + Widget cardContent; + + if (enableBlur) { + cardContent = ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + width: width, + height: height, + padding: padding, + decoration: cardDecoration, + child: child, + ), + ), + ); + } else { + cardContent = Container( + width: width, + height: height, + padding: padding, + decoration: cardDecoration, + child: child, + ); + } + + + + if (margin != null) { + + cardContent = Padding(padding: margin!, child: cardContent); + + } + + + + if (onTap != null) { + + return GestureDetector( + + onTap: onTap, + + child: cardContent, + + ); + + } + + + + return cardContent; + + } + +} + diff --git a/apps/eva_mobile/lib/widgets/luna_loading_orb.dart b/apps/eva_mobile/lib/widgets/luna_loading_orb.dart new file mode 100644 index 0000000..ee644fb --- /dev/null +++ b/apps/eva_mobile/lib/widgets/luna_loading_orb.dart @@ -0,0 +1,304 @@ +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 Luna 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 LunaLoadingOrb extends StatefulWidget { + final double size; + final String? message; + final bool showHalo; + + const LunaLoadingOrb({ + super.key, + this.size = 180.0, + this.message, + this.showHalo = true, + }); + + @override + State createState() => _LunaLoadingOrbState(); +} + +class _LunaLoadingOrbState extends State + 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: _LunaOrbFluidPainter( + 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 _LunaOrbFluidPainter extends CustomPainter { + final double animProgress; + + const _LunaOrbFluidPainter({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 _LunaOrbFluidPainter oldDelegate) { + return oldDelegate.animProgress != animProgress; + } +} + +// Brand migration alias +typedef EvaLoadingOrb = LunaLoadingOrb; diff --git a/apps/eva_mobile/lib/widgets/mood_checkin_bottom_sheet.dart b/apps/eva_mobile/lib/widgets/mood_checkin_bottom_sheet.dart new file mode 100644 index 0000000..f3017ae --- /dev/null +++ b/apps/eva_mobile/lib/widgets/mood_checkin_bottom_sheet.dart @@ -0,0 +1,797 @@ + +import 'package:flutter/material.dart'; + +import 'package:google_fonts/google_fonts.dart'; + +import '../theme/app_colors.dart'; + +import 'custom_button.dart'; + + + +class MoodCheckinBottomSheet extends StatefulWidget { + + final String initialMoodLabel; + + final String initialMoodEmoji; + + final Function(String moodLabel, String moodEmoji) onSave; + + + + const MoodCheckinBottomSheet({ + + super.key, + + required this.initialMoodLabel, + + required this.initialMoodEmoji, + + required this.onSave, + + }); + + + + @override + + State createState() => _MoodCheckinBottomSheetState(); + +} + + + +class _MoodCheckinBottomSheetState extends State { + + late String _selectedMoodLabel; + + late String _selectedMoodEmoji; + + + + final List> _moodOptions = const [ + + {'emoji': '😌', 'label': 'Tenang & Damai'}, + + {'emoji': '😃', 'label': 'Bahagia & Berenergi'}, + + {'emoji': '😰', 'label': 'Cemas & Gelisah'}, + + {'emoji': '😔', 'label': 'Sedih & Haru'}, + + {'emoji': '😴', 'label': 'Lelah & Kewalahan'}, + + ]; + + + + final List _triggerOptions = [ + + '📚 Studi / Ujian', + + 'đŸ’ŧ Pekerjaan', + + '💤 Kualitas Tidur', + + 'đŸ‘Ĩ Hubungan', + + '🏠 Keluarga', + + ]; + + + + final Set _selectedTriggers = {'📚 Studi / Ujian'}; + + final TextEditingController _noteController = TextEditingController(); + + final TextEditingController _customTriggerController = TextEditingController(); + + + + @override + + void initState() { + + super.initState(); + + _selectedMoodLabel = widget.initialMoodLabel; + + _selectedMoodEmoji = widget.initialMoodEmoji; + + } + + + + @override + + void dispose() { + + _noteController.dispose(); + + _customTriggerController.dispose(); + + super.dispose(); + + } + + + + void _showAddCustomTriggerDialog() { + + _customTriggerController.clear(); + + showDialog( + + context: context, + + builder: (context) { + + return AlertDialog( + + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + + title: Text( + + 'Tambah Pemicu Baru', + + style: GoogleFonts.inter(fontSize: 18, fontWeight: FontWeight.w700), + + ), + + content: Column( + + mainAxisSize: MainAxisSize.min, + + children: [ + + Text( + + 'Masukkan pemicu emosi kustom yang tidak ada dalam daftar.', + + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textSecondary), + + ), + + const SizedBox(height: 14), + + TextField( + + controller: _customTriggerController, + + autofocus: true, + + style: GoogleFonts.inter(fontSize: 14), + + decoration: InputDecoration( + + hintText: 'Contoh: Keuangan, Cuaca, Kesehatan', + + hintStyle: GoogleFonts.inter(fontSize: 13, color: AppColors.textLight), + + filled: true, + + fillColor: const Color(0xFFF4F6FF), + + border: OutlineInputBorder( + + borderRadius: BorderRadius.circular(16), + + borderSide: BorderSide.none, + + ), + + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + + ), + + ), + + ], + + ), + + actions: [ + + TextButton( + + onPressed: () => Navigator.pop(context), + + child: Text( + + 'Batal', + + style: GoogleFonts.inter(color: AppColors.textSecondary), + + ), + + ), + + ElevatedButton( + + style: ElevatedButton.styleFrom( + + backgroundColor: AppColors.primary, + + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + + ), + + onPressed: () { + + final text = _customTriggerController.text.trim(); + + if (text.isNotEmpty) { + + final formattedTag = '✨ $text'; + + setState(() { + + if (!_triggerOptions.contains(formattedTag)) { + + _triggerOptions.add(formattedTag); + + } + + _selectedTriggers.add(formattedTag); + + }); + + Navigator.pop(context); + + } + + }, + + child: Text( + + 'Tambah', + + style: GoogleFonts.inter(color: Colors.white, fontWeight: FontWeight.w600), + + ), + + ), + + ], + + ); + + }, + + ); + + } + + + + @override + + Widget build(BuildContext context) { + + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + + return Container( + padding: EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 24.0 + bottomInset), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.98), + borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), + border: Border.all( + color: Colors.white.withValues(alpha: 0.8), + width: 1.5, + ), + ), + + child: SingleChildScrollView( + + child: Column( + + mainAxisSize: MainAxisSize.min, + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + // Handle Bar + + Center( + + child: Container( + + width: 48, + + height: 5, + + decoration: BoxDecoration( + + color: Colors.grey.shade300, + + borderRadius: BorderRadius.circular(999), + + ), + + ), + + ), + + const SizedBox(height: 20), + + + + // Header Title + + Row( + + children: [ + + Container( + + width: 42, + + height: 42, + + decoration: BoxDecoration( + + color: AppColors.primaryContainer, + + borderRadius: BorderRadius.circular(14), + + ), + + child: const Icon( + + Icons.spa_outlined, + + color: AppColors.primary, + + size: 22, + + ), + + ), + + const SizedBox(width: 14), + + Expanded( + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + Text( + + 'Bagaimana Suasana Hatimu?', + + style: GoogleFonts.inter( + + fontSize: 18, + + fontWeight: FontWeight.w800, + + color: AppColors.textPrimary, + + ), + + ), + + Text( + + 'Pilih emosi & pemicu utama yang kamu rasakan.', + + style: GoogleFonts.inter( + + fontSize: 12, + + color: AppColors.textSecondary, + + ), + + ), + + ], + + ), + + ), + + ], + + ), + + const SizedBox(height: 24), + + + + // 1. Select Mood Options + + Text( + + 'PILIH EMOSI', + + style: GoogleFonts.inter( + + fontSize: 11, + + fontWeight: FontWeight.w700, + + color: AppColors.textLight, + + letterSpacing: 0.8, + + ), + + ), + + const SizedBox(height: 12), + + SizedBox( + + height: 90, + + child: ListView.separated( + + scrollDirection: Axis.horizontal, + + itemCount: _moodOptions.length, + + separatorBuilder: (context, index) => const SizedBox(width: 10), + + itemBuilder: (context, index) { + + final item = _moodOptions[index]; + + final isSelected = item['label'] == _selectedMoodLabel; + + return GestureDetector( + + onTap: () { + + setState(() { + + _selectedMoodLabel = item['label']!; + + _selectedMoodEmoji = item['emoji']!; + + }); + + }, + + child: AnimatedContainer( + + duration: const Duration(milliseconds: 200), + + width: 80, + + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + + decoration: BoxDecoration( + + color: isSelected + + ? AppColors.primaryContainer + + : const Color(0xFFF4F6FF), + + borderRadius: BorderRadius.circular(20), + + border: Border.all( + + color: isSelected + + ? AppColors.primary + + : Colors.transparent, + + width: 2, + + ), + + boxShadow: isSelected + + ? [ + + BoxShadow( + + color: AppColors.primary.withValues(alpha: 0.15), + + blurRadius: 10, + + offset: const Offset(0, 4), + + ), + + ] + + : null, + + ), + + child: Column( + + mainAxisAlignment: MainAxisAlignment.center, + + children: [ + + Text( + + item['emoji']!, + + style: const TextStyle(fontSize: 26), + + ), + + const SizedBox(height: 6), + + Text( + + item['label']!.split(' ').first, + + maxLines: 1, + + overflow: TextOverflow.ellipsis, + + style: GoogleFonts.inter( + + fontSize: 11, + + fontWeight: isSelected + + ? FontWeight.w700 + + : FontWeight.w500, + + color: isSelected + + ? AppColors.primary + + : AppColors.textSecondary, + + ), + + ), + + ], + + ), + + ), + + ); + + }, + + ), + + ), + + const SizedBox(height: 24), + + + + // 2. Select Trigger Factors (No Checkmark Icon + Add Custom Button) + + Text( + + 'PEMICU UTAMA', + + style: GoogleFonts.inter( + + fontSize: 11, + + fontWeight: FontWeight.w700, + + color: AppColors.textLight, + + letterSpacing: 0.8, + + ), + + ), + + const SizedBox(height: 10), + + Wrap( + + spacing: 8, + + runSpacing: 8, + + children: [ + + ..._triggerOptions.map((trigger) { + + final isSelected = _selectedTriggers.contains(trigger); + + return ChoiceChip( + + showCheckmark: false, + + label: Text(trigger), + + selected: isSelected, + + selectedColor: const Color(0xFFE2DAFF), + + backgroundColor: const Color(0xFFF4F6FF), + + labelStyle: GoogleFonts.inter( + + fontSize: 12, + + fontWeight: isSelected ? FontWeight.w600 : 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(() { + + if (selected) { + + _selectedTriggers.add(trigger); + + } else { + + _selectedTriggers.remove(trigger); + + } + + }); + + }, + + ); + + }), + + + + // Add Custom Trigger Action Chip + + ActionChip( + + avatar: const Icon(Icons.add, size: 16, color: AppColors.primary), + + label: Text( + + 'Tambah', + + style: GoogleFonts.inter( + + fontSize: 12, + + fontWeight: FontWeight.w600, + + color: AppColors.primary, + + ), + + ), + + backgroundColor: AppColors.primaryContainer.withValues(alpha: 0.6), + + shape: RoundedRectangleBorder( + + borderRadius: BorderRadius.circular(999), + + side: const BorderSide(color: AppColors.primary, width: 1), + + ), + + onPressed: _showAddCustomTriggerDialog, + + ), + + ], + + ), + + const SizedBox(height: 24), + + + + // 3. Short Note TextField + + Text( + + 'CATATAN SINGKAT (OPSIONAL)', + + style: GoogleFonts.inter( + + fontSize: 11, + + fontWeight: FontWeight.w700, + + color: AppColors.textLight, + + letterSpacing: 0.8, + + ), + + ), + + const SizedBox(height: 10), + + Container( + + decoration: BoxDecoration( + + color: const Color(0xFFF4F6FF), + + borderRadius: BorderRadius.circular(16), + + border: Border.all(color: Colors.grey.shade300), + + ), + + child: TextField( + + controller: _noteController, + + maxLines: 2, + + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textPrimary), + + decoration: InputDecoration( + + hintText: 'Ceritakan sedikit pemicu perasaanmu hari ini...', + + hintStyle: GoogleFonts.inter(fontSize: 13, color: AppColors.textLight), + + border: InputBorder.none, + + contentPadding: const EdgeInsets.all(14), + + ), + + ), + + ), + + const SizedBox(height: 28), + + + + // 4. Save Button + + CustomPillButton( + + text: 'Simpan Suasana Hati', + + onPressed: () { + + widget.onSave(_selectedMoodLabel, _selectedMoodEmoji); + + Navigator.pop(context); + + }, + + ), + + const SizedBox(height: 16), + + ], + + ), + ), + ); + } + +} + diff --git a/apps/eva_mobile/lib/widgets/skeleton_shimmer.dart b/apps/eva_mobile/lib/widgets/skeleton_shimmer.dart new file mode 100644 index 0000000..df945c9 --- /dev/null +++ b/apps/eva_mobile/lib/widgets/skeleton_shimmer.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; + +/// An InheritedWidget that provides the synchronized breathing shimmer opacity +/// to all child skeleton items within its subtree. +class _SkeletonInheritedOpacity extends InheritedWidget { + final double opacity; + + const _SkeletonInheritedOpacity({ + required this.opacity, + required super.child, + }); + + static double of(BuildContext context) { + final inherited = + context.dependOnInheritedWidgetOfExactType<_SkeletonInheritedOpacity>(); + return inherited?.opacity ?? 0.55; + } + + @override + bool updateShouldNotify(_SkeletonInheritedOpacity oldWidget) { + return oldWidget.opacity != opacity; + } +} + +/// A lightweight, GPU-accelerated breathing shimmer container that synchronizes +/// opacity animations for all [SkeletonBox], [SkeletonLine], and [SkeletonCircle] children. +class SkeletonShimmerHost extends StatefulWidget { + final Widget child; + final Duration duration; + final double minOpacity; + final double maxOpacity; + + const SkeletonShimmerHost({ + super.key, + required this.child, + this.duration = const Duration(milliseconds: 1100), + this.minOpacity = 0.28, + this.maxOpacity = 0.68, + }); + + @override + State createState() => _SkeletonShimmerHostState(); +} + +class _SkeletonShimmerHostState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: widget.duration, + )..repeat(reverse: true); + + _animation = Tween( + begin: widget.minOpacity, + end: widget.maxOpacity, + ).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) { + return _SkeletonInheritedOpacity( + opacity: _animation.value, + child: widget.child, + ); + }, + ); + } +} + +/// A rectangular skeleton placeholder with customizable dimensions and border radius. +class SkeletonBox extends StatelessWidget { + final double? width; + final double? height; + final double borderRadius; + final EdgeInsetsGeometry? margin; + final EdgeInsetsGeometry? padding; + final Color? baseColor; + final Widget? child; + + const SkeletonBox({ + super.key, + this.width, + this.height, + this.borderRadius = 14.0, + this.margin, + this.padding, + this.baseColor, + this.child, + }); + + @override + Widget build(BuildContext context) { + final opacity = _SkeletonInheritedOpacity.of(context); + final color = baseColor ?? AppColors.primary; + + return Container( + width: width, + height: height, + margin: margin, + padding: padding, + decoration: BoxDecoration( + color: color.withValues(alpha: opacity * 0.25), + borderRadius: BorderRadius.circular(borderRadius), + ), + child: child, + ); + } +} + +/// A line skeleton placeholder ideal for simulated text lines. +class SkeletonLine extends StatelessWidget { + final double? width; + final double height; + final double borderRadius; + final EdgeInsetsGeometry? margin; + final Color? baseColor; + + const SkeletonLine({ + super.key, + this.width, + this.height = 14.0, + this.borderRadius = 6.0, + this.margin, + this.baseColor, + }); + + @override + Widget build(BuildContext context) { + return SkeletonBox( + width: width, + height: height, + borderRadius: borderRadius, + margin: margin, + baseColor: baseColor, + ); + } +} + +/// A circular skeleton placeholder ideal for avatars, icons, or indicators. +class SkeletonCircle extends StatelessWidget { + final double size; + final EdgeInsetsGeometry? margin; + final Color? baseColor; + + const SkeletonCircle({ + super.key, + this.size = 44.0, + this.margin, + this.baseColor, + }); + + @override + Widget build(BuildContext context) { + final opacity = _SkeletonInheritedOpacity.of(context); + final color = baseColor ?? AppColors.primary; + + return Container( + width: size, + height: size, + margin: margin, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color.withValues(alpha: opacity * 0.3), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/widgets/social_auth_dev_sheet.dart b/apps/eva_mobile/lib/widgets/social_auth_dev_sheet.dart new file mode 100644 index 0000000..40714f0 --- /dev/null +++ b/apps/eva_mobile/lib/widgets/social_auth_dev_sheet.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; + +/// Bottom sheet informatif ketika tombol registrasi/login sosial media ditekan, +/// mengarahkan pengguna untuk mendaftar atau masuk menggunakan form email yang tersedia. +void showSocialAuthDevSheet(BuildContext context, {required String provider}) { + final providerName = provider.toLowerCase() == 'apple' ? 'Apple ID' : 'Google'; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (BuildContext ctx) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + boxShadow: [ + BoxShadow( + color: Color(0x1A1E1B4B), + blurRadius: 24, + offset: Offset(0, -6), + ), + ], + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 24), + + // Icon badge + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFFFDE68A), width: 1.5), + ), + child: const Center( + child: Icon( + Icons.construction_rounded, + color: Color(0xFFD97706), + size: 32, + ), + ), + ), + const SizedBox(height: 18), + + // Title + Text( + 'Fitur Sedang Dalam Pengembangan', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + + // Description + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + 'Metode pendaftaran dan masuk dengan $providerName saat ini masih dalam tahap pengembangan. Silakan gunakan form email yang telah tersedia untuk melanjutkan.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + height: 1.5, + color: AppColors.textSecondary, + ), + ), + ), + const SizedBox(height: 24), + + // Action button + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: () => Navigator.pop(ctx), + child: Text( + 'Mengerti, Gunakan Form Email', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/apps/eva_mobile/lib/widgets/staggered_entrance.dart b/apps/eva_mobile/lib/widgets/staggered_entrance.dart new file mode 100644 index 0000000..0c2efa3 --- /dev/null +++ b/apps/eva_mobile/lib/widgets/staggered_entrance.dart @@ -0,0 +1,89 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +/// Lightweight, GPU-accelerated entrance animation that staggers content +/// appearance (fade + gentle upward slide) one-by-one based on [index]. +class StaggeredEntrance extends StatefulWidget { + final int index; + final Widget child; + final Duration baseDelay; + final Duration staggerInterval; + final Duration duration; + final Offset slideOffset; + + const StaggeredEntrance({ + super.key, + required this.index, + required this.child, + this.baseDelay = const Duration(milliseconds: 60), + this.staggerInterval = const Duration(milliseconds: 40), + this.duration = const Duration(milliseconds: 320), + this.slideOffset = const Offset(0.0, 0.08), + }); + + @override + State createState() => _StaggeredEntranceState(); +} + +class _StaggeredEntranceState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + Timer? _delayTimer; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: widget.duration, + ); + + final curved = CurvedAnimation( + parent: _animController, + curve: Curves.easeOutCubic, + ); + + _fadeAnimation = CurvedAnimation( + parent: _animController, + curve: Curves.easeOut, + ); + + _slideAnimation = Tween( + begin: widget.slideOffset, + end: Offset.zero, + ).animate(curved); + + final delayMs = widget.baseDelay.inMilliseconds + + (widget.index.clamp(0, 8) * widget.staggerInterval.inMilliseconds); + + if (delayMs <= 0) { + _animController.forward(); + } else { + _delayTimer = Timer(Duration(milliseconds: delayMs), () { + if (mounted) { + _animController.forward(); + } + }); + } + } + + @override + void dispose() { + _delayTimer?.cancel(); + _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/widgets/web_phone_frame_wrapper.dart b/apps/eva_mobile/lib/widgets/web_phone_frame_wrapper.dart new file mode 100644 index 0000000..2cb05f7 --- /dev/null +++ b/apps/eva_mobile/lib/widgets/web_phone_frame_wrapper.dart @@ -0,0 +1,215 @@ +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Wraps the application inside a sleek, modern smartphone frame when viewed on +/// desktop/wide web browsers (width > 500px). +/// +/// When opened on mobile devices or narrow viewports (width <= 500px), or in native mobile builds, +/// it seamlessly renders full-screen without any outer frame. +class WebPhoneFrameWrapper extends StatelessWidget { + final Widget child; + + const WebPhoneFrameWrapper({ + super.key, + required this.child, + }); + + static const double maxPhoneWidth = 420.0; + static const double maxPhoneHeight = 900.0; + static const double breakpointWidth = 500.0; + + @override + Widget build(BuildContext context) { + // On native mobile builds (Android/iOS APK), never wrap with desktop frame + if (!kIsWeb) { + return child; + } + + final mediaQuery = MediaQuery.of(context); + final screenWidth = mediaQuery.size.width; + final screenHeight = mediaQuery.size.height; + + // On mobile web browser (e.g. mobile Safari / Chrome on Android), render full-screen directly + if (screenWidth <= breakpointWidth) { + return child; + } + + // Desktop/Tablet Web View: Center within a premium modern phone mockup frame + final double phoneWidth = math.min(screenWidth - 48.0, maxPhoneWidth); + final double phoneHeight = math.min(screenHeight - 56.0, maxPhoneHeight); + + return Scaffold( + backgroundColor: const Color(0xFF0C0A14), + body: Stack( + alignment: Alignment.center, + children: [ + // 1. Ambient atmospheric glow in desktop background + Positioned( + top: screenHeight * 0.15, + left: (screenWidth / 2) - 260, + child: Container( + width: 520, + height: 520, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + const Color(0xFF6366F1).withValues(alpha: 0.14), + const Color(0xFFA855F7).withValues(alpha: 0.08), + Colors.transparent, + ], + stops: const [0.0, 0.55, 1.0], + ), + ), + ), + ), + + // 2. Top-left branding pill on desktop browser + Positioned( + top: 20, + left: 28, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white.withValues(alpha: 0.12)), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFF10B981), + ), + ), + const SizedBox(width: 8), + Text( + 'EVA AI â€ĸ Mobile Web Simulator', + style: GoogleFonts.inter( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + ], + ), + ), + ], + ), + ), + + // 3. Central Smartphone Frame + Center( + child: Container( + width: phoneWidth, + height: phoneHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(46.0), + border: Border.all( + color: const Color(0xFF262438), + width: 10.0, + ), + boxShadow: [ + // Deep atmospheric elevation shadow + BoxShadow( + color: Colors.black.withValues(alpha: 0.70), + blurRadius: 48, + spreadRadius: 4, + offset: const Offset(0, 16), + ), + // Outer subtle violet bezel illumination + BoxShadow( + color: const Color(0xFF818CF8).withValues(alpha: 0.20), + blurRadius: 32, + spreadRadius: 0, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(36.0), + child: Stack( + children: [ + // Inner application content with overridden phone-sized MediaQuery + Positioned.fill( + child: MediaQuery( + data: mediaQuery.copyWith( + size: Size(phoneWidth, phoneHeight), + padding: const EdgeInsets.only(top: 28, bottom: 20), + viewPadding: const EdgeInsets.only(top: 28, bottom: 20), + ), + child: child, + ), + ), + + // Top Dynamic Island / Camera Pill Mockup + Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Container( + width: 96, + height: 20, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: Colors.white.withValues(alpha: 0.08), + width: 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + margin: const EdgeInsets.only(right: 8), + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF1E1B4B).withValues(alpha: 0.8), + border: Border.all( + color: const Color(0xFF38BDF8).withValues(alpha: 0.4), + width: 1, + ), + ), + ), + ], + ), + ), + ), + ), + + // Bottom Home Gesture Indicator Line + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: Container( + width: 120, + height: 4, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/pubspec.lock b/apps/eva_mobile/pubspec.lock index 57a71ee..cba198d 100644 --- a/apps/eva_mobile/pubspec.lock +++ b/apps/eva_mobile/pubspec.lock @@ -300,10 +300,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -316,10 +316,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" objective_c: dependency: transitive description: @@ -577,10 +577,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" typed_data: dependency: transitive description: @@ -665,10 +665,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: fa49e1674cb84acb900141714e6c6e1f9ae099827c24d141430ff6819db4e1bc url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.4" vm_service: dependency: transitive description: diff --git a/scripts/DOCKER_SETUP.md b/scripts/DOCKER_SETUP.md index df3e34f..107655f 100644 --- a/scripts/DOCKER_SETUP.md +++ b/scripts/DOCKER_SETUP.md @@ -1,6 +1,6 @@ -# đŸŗ Panduan Setup Docker (Ringan, Cepat & Hot-Reload) - LUNA AI +# đŸŗ Panduan Setup Docker (Ringan, Cepat & Hot-Reload) - EVA AI -Dokumen ini berisi panduan lengkap setup Docker yang **ringan, cepat, dan terintegrasi dalam 1 berkas** untuk monorepo **LUNA AI**. Panduan ini sudah mencakup konfigurasi `Dockerfile` (`api`, `mcp`, `workers`), `docker-compose.yml`, `.dockerignore`, serta script runner otomatisasi `scripts/docker-dev.sh` & `scripts/docker-dev.ps1`. +Dokumen ini berisi panduan lengkap setup Docker yang **ringan, cepat, dan terintegrasi dalam 1 berkas** untuk monorepo **EVA AI**. Panduan ini sudah mencakup konfigurasi `Dockerfile` (`api`, `mcp`, `workers`), `docker-compose.yml`, `.dockerignore`, serta script runner otomatisasi `scripts/docker-dev.sh` & `scripts/docker-dev.ps1`. --- @@ -38,7 +38,7 @@ Pastikan perangkat Anda sudah terinstall: ## 📁 Struktur File ```text -luna_ai/ +eva-ecosystem/ ├── apps/ │ └── backend/ │ ├── api/Dockerfile @@ -102,4 +102,4 @@ luna_ai/ | **Qdrant Vector DB (`luna_qdrant`)** | `6333` | `6333` | Vector Search Engine | --- -*Dokumen ini dibuat otomatis untuk penyiapan lingkungan Docker LUNA AI yang ringan, cepat, dan siap pakai.* +*Dokumen ini dibuat otomatis untuk penyiapan lingkungan Docker EVA AI yang ringan, cepat, dan siap pakai.* diff --git a/scripts/build-and-deploy-web.sh b/scripts/build-and-deploy-web.sh index 5d1b550..90e9346 100755 --- a/scripts/build-and-deploy-web.sh +++ b/scripts/build-and-deploy-web.sh @@ -3,7 +3,7 @@ set -e # ============================================================================== # Script: build-and-deploy-web.sh -# Purpose: Build and deploy Luna Mobile Flutter Web to /var/www/luna-web +# Purpose: Build and deploy Eva Mobile Flutter Web to /var/www/eva-web # Usage: # ./build-and-deploy-web.sh # Full build & deploy # ./build-and-deploy-web.sh --build-only # Only compile web release @@ -11,10 +11,10 @@ set -e # ============================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_DIR="$(cd "$SCRIPT_DIR/../apps/luna_mobile" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/../apps/eva_mobile" && pwd)" BUILD_DIR="$APP_DIR/build/web" -WEB_ROOT="/var/www/luna-web" -DOMAIN="luna.nexacode.dev" +WEB_ROOT="/var/www/eva-web" +DOMAIN="eva.nexacode.dev" export PATH="$PATH:/opt/flutter/bin" export CI=true @@ -33,7 +33,7 @@ if [ "$MODE" = "all" ] || [ "$MODE" = "build" ]; then exit 1 fi - echo "==> [1/3] Fetching dependencies for Luna Mobile..." + echo "==> [1/3] Fetching dependencies for Eva Mobile..." (cd "$APP_DIR" && flutter pub get) echo "==> [2/3] Building Flutter Web (release, host: $DOMAIN)..." diff --git a/scripts/deploy-web.sh b/scripts/deploy-web.sh index 593cbab..330396e 100755 --- a/scripts/deploy-web.sh +++ b/scripts/deploy-web.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -e -ARCHIVE_PATH="${1:-/opt/luna_ai/apps/luna_mobile/web.tar.gz}" -WEB_ROOT="/var/www/luna-web" +ARCHIVE_PATH="${1:-/opt/eva-ecosystem/apps/eva_mobile/web.tar.gz}" +WEB_ROOT="/var/www/eva-web" if [ ! -f "$ARCHIVE_PATH" ]; then echo "Error: Archive file not found at $ARCHIVE_PATH" @@ -24,4 +24,4 @@ chown -R www-data:www-data "$WEB_ROOT" chmod -R 755 "$WEB_ROOT" systemctl reload nginx -echo "Deployment successful! Check https://luna.nexacode.dev" +echo "Deployment successful! Check https://eva.nexacode.dev" diff --git a/scripts/docker-prod.sh b/scripts/docker-prod.sh index 3874de2..593c225 100755 --- a/scripts/docker-prod.sh +++ b/scripts/docker-prod.sh @@ -59,7 +59,7 @@ case "$ACTION" in ;; restart) - echo "🔄 Re-applying configuration and restarting LUNA AI Production stack..." + echo "🔄 Re-applying configuration and restarting EVA AI Production stack..." docker compose -f "${COMPOSE_FILE}" up -d "$@" echo "" echo "==> Waiting for services..." diff --git a/scripts/run_mobile.sh b/scripts/run_mobile.sh index dbd93fb..c9c6519 100755 --- a/scripts/run_mobile.sh +++ b/scripts/run_mobile.sh @@ -78,7 +78,8 @@ else exit 1 fi -APP_DIR="${ROOT_DIR}/apps/eva_mobile" +APP_TARGET="${APP:-eva_mobile}" +APP_DIR="${ROOT_DIR}/apps/${APP_TARGET}" if [ ! -d "${APP_DIR}" ]; then echo -e "${RED}${BOLD}[ERROR]${NC} Direktori aplikasi tidak ditemukan: ${APP_DIR}" exit 1 diff --git a/scripts/trigger-build.sh b/scripts/trigger-build.sh index 70219bd..4686376 100755 --- a/scripts/trigger-build.sh +++ b/scripts/trigger-build.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ============================================================================== # Script Pemicu GitHub Actions Build Mobile (Android APK & iOS Unsigned) -# Repositori: Nexa-Code-Studio/luna_ai +# Repositori: Nexa-Code-Studio/eva-ecosystem # ============================================================================== set -e @@ -24,7 +24,7 @@ CONFIG="release" MODE="auto" DRY_RUN=false BRANCH="$(git -C "$ROOT_DIR" branch --show-current 2>/dev/null || echo "test")" -REPO_DEFAULT="Nexa-Code-Studio/luna_ai" +REPO_DEFAULT="Nexa-Code-Studio/eva-ecosystem" # Deteksi nama repository dari remote origin get_repo_slug() { @@ -138,7 +138,7 @@ load_token() { TOKEN="$(load_token)" echo -e "${CYAN}======================================================${NC}" -echo -e "${CYAN} LUNA AI - Mobile Build Workflow Trigger ${NC}" +echo -e "${CYAN} EVA AI - Mobile Build Workflow Trigger ${NC}" echo -e "${CYAN}======================================================${NC}" echo -e "Repositori : ${GREEN}${REPO_SLUG}${NC}" echo -e "Branch : ${GREEN}${BRANCH}${NC}"