From 7dac65f9c58ee762dae96a6a9c83c0788338d79c Mon Sep 17 00:00:00 2001 From: ekyaaa Date: Sat, 7 Nov 2026 16:02:48 +0700 Subject: [PATCH 1/7] feat(mobile): restore and track Eva Mobile client architecture and test suite - Track entire apps/eva_mobile/lib/ codebase under Git version control. - Ensure root .gitignore excludes !apps/eva_mobile/lib/ from repository ignore rules. - Structure client around Clean Architecture with domain entities, abstract repositories, and dual-source data layer (Mock vs Remote API). - Implement feature modules: auth, care, chat, dass, diary, emergency, home, monitoring, recommendations, tutorial, and voice_call. - Integrate Riverpod state management, centralized routing in AppRouter with custom route transitions, and responsive mobile design system (GlassCard, AppBackButton, AppSnackBar). - Align unit, screen, and widget test suites in apps/eva_mobile/test/ with current feature provider contracts. Closes #28 --- .gitignore | 1 + apps/eva_mobile/AGENTS.md | 10 + apps/eva_mobile/lib/config/app_config.dart | 303 +++ .../lib/core/config/app_config.dart | 2 + .../lib/core/network/api_client.dart | 95 + .../lib/core/network/fallback_policy.dart | 84 + .../core/routes/app_route_transitions.dart | 129 ++ .../lib/core/routes/app_router.dart | 136 ++ .../lib/core/storage/token_storage.dart | 23 + .../core/utils/responsive_layout_helper.dart | 16 + .../repositories/voice_call_repository.dart | 227 +++ .../data/datasources/auth_datasource.dart | 13 + .../datasources/mock_auth_datasource.dart | 102 + .../datasources/remote_auth_datasource.dart | 106 ++ .../data/models/emergency_contact_model.dart | 47 + .../features/auth/data/models/user_model.dart | 52 + .../repositories/auth_repository_impl.dart | 44 + .../fallback_auth_repository.dart | 87 + .../entities/emergency_contact_entity.dart | 15 + .../auth/domain/entities/user_entity.dart | 17 + .../domain/repositories/auth_repository.dart | 13 + .../presentation/providers/auth_provider.dart | 32 + .../presentation/screens/login_screen.dart | 294 +++ .../presentation/screens/profile_screen.dart | 994 ++++++++++ .../presentation/screens/register_screen.dart | 439 +++++ .../presentation/screens/settings_screen.dart | 928 ++++++++++ .../screens/splash_onboarding_screen.dart | 322 ++++ .../screens/terms_disclaimer_screen.dart | 244 +++ .../data/datasources/care_datasource.dart | 19 + .../datasources/mock_care_datasource.dart | 260 +++ .../datasources/remote_care_datasource.dart | 90 + .../care/data/models/clinic_model.dart | 46 + .../data/models/community_status_model.dart | 39 + .../models/consultation_session_model.dart | 68 + .../care/data/models/psychologist_model.dart | 61 + .../repositories/care_repository_impl.dart | 52 + .../fallback_care_repository.dart | 86 + .../care/domain/entities/clinic_entity.dart | 26 + .../entities/community_status_entity.dart | 42 + .../entities/consultation_session_entity.dart | 41 + .../domain/entities/psychologist_entity.dart | 38 + .../domain/repositories/care_repository.dart | 32 + .../presentation/providers/care_provider.dart | 140 ++ .../presentation/screens/care_screen.dart | 539 ++++++ .../widgets/booking_consultation_sheet.dart | 459 +++++ .../presentation/widgets/clinic_card.dart | 185 ++ .../widgets/community_banner_card.dart | 392 ++++ .../widgets/community_paywall_sheet.dart | 370 ++++ .../widgets/consultation_history_card.dart | 217 +++ .../widgets/psychologist_card.dart | 238 +++ .../data/datasources/chat_datasource.dart | 9 + .../datasources/mock_chat_datasource.dart | 124 ++ .../datasources/remote_chat_datasource.dart | 80 + .../chat/data/models/chat_message_model.dart | 57 + .../chat/data/models/conversation_model.dart | 42 + .../repositories/chat_repository_impl.dart | 23 + .../fallback_chat_repository.dart | 51 + .../domain/entities/chat_message_entity.dart | 19 + .../domain/entities/conversation_entity.dart | 23 + .../domain/repositories/chat_repository.dart | 9 + .../presentation/providers/chat_provider.dart | 31 + .../screens/ai_conversation_screen.dart | 587 ++++++ .../widgets/animated_session_card.dart | 72 + .../widgets/session_list_skeleton.dart | 120 ++ .../data/datasources/dass_datasource.dart | 176 ++ .../data/models/dass_assessment_model.dart | 115 ++ .../repositories/dass_repository_impl.dart | 24 + .../fallback_dass_repository.dart | 41 + .../entities/dass_assessment_entity.dart | 103 ++ .../domain/repositories/dass_repository.dart | 7 + .../presentation/providers/dass_provider.dart | 146 ++ .../screens/dass_assessment_screen.dart | 1641 ++++++++++++++++ .../presentation/widgets/bouncing_button.dart | 74 + .../widgets/dass_ai_analysis_accordion.dart | 292 +++ .../presentation/widgets/dass_form_card.dart | 466 +++++ .../widgets/dass_skeleton_loader.dart | 111 ++ .../data/datasources/diary_datasource.dart | 10 + .../datasources/mock_diary_datasource.dart | 189 ++ .../datasources/remote_diary_datasource.dart | 55 + .../diary/data/models/diary_entry_model.dart | 90 + .../diary/data/models/risk_warning_model.dart | 31 + .../repositories/diary_repository_impl.dart | 29 + .../fallback_diary_repository.dart | 68 + .../domain/entities/diary_entry_entity.dart | 49 + .../domain/entities/risk_warning_entity.dart | 15 + .../domain/repositories/diary_repository.dart | 10 + .../providers/diary_provider.dart | 36 + .../screens/ai_diary_detail_screen.dart | 1647 +++++++++++++++++ .../presentation/screens/ai_diary_screen.dart | 726 ++++++++ .../widgets/summary_skeleton_loader.dart | 76 + .../screens/emergency_contacts_screen.dart | 707 +++++++ .../screens/support_emergency_screen.dart | 443 +++++ .../widgets/breathing_exercise_modal.dart | 235 +++ .../widgets/contact_form_dialog.dart | 419 +++++ .../emergency_contacts_skeleton_loader.dart | 59 + .../widgets/emergency_hotline_modal.dart | 207 +++ .../presentation/screens/home_screen.dart | 1629 ++++++++++++++++ .../screens/main_shell_screen.dart | 429 +++++ .../widgets/home_activity_modal.dart | 445 +++++ .../widgets/profile_dropdown_modal.dart | 292 +++ .../mock_monitoring_datasource.dart | 106 ++ .../datasources/monitoring_datasource.dart | 5 + .../remote_monitoring_datasource.dart | 15 + .../data/models/monitoring_data_model.dart | 109 ++ .../fallback_monitoring_repository.dart | 23 + .../monitoring_repository_impl.dart | 12 + .../entities/monitoring_data_entity.dart | 51 + .../repositories/monitoring_repository.dart | 5 + .../providers/monitoring_provider.dart | 29 + .../screens/monitoring_screen.dart | 772 ++++++++ .../widgets/monitoring_dass_card.dart | 946 ++++++++++ .../stacked_emotion_chart_painter.dart | 70 + .../mock_recommendation_datasource.dart | 103 ++ .../recommendation_datasource.dart | 7 + .../remote_recommendation_datasource.dart | 30 + .../data/models/recommendation_model.dart | 57 + .../fallback_recommendation_repository.dart | 41 + .../recommendation_repository_impl.dart | 18 + .../entities/recommendation_entity.dart | 19 + .../recommendation_repository.dart | 7 + .../providers/recommendation_provider.dart | 26 + .../screens/recommendation_screen.dart | 1229 ++++++++++++ .../widgets/guidance_modal_content.dart | 413 +++++ .../recommendation_skeleton_loader.dart | 102 + .../tutorial_repository_impl.dart | 32 + .../domain/entities/tutorial_step.dart | 80 + .../repositories/tutorial_repository.dart | 5 + .../providers/tutorial_provider.dart | 142 ++ .../widgets/branded_guidance_card.dart | 300 +++ .../interactive_spotlight_overlay.dart | 349 ++++ .../voice_call/config/call_config.dart | 22 + .../mock_voice_call_datasource.dart | 41 + .../remote_voice_call_datasource.dart | 30 + .../datasources/voice_call_datasource.dart | 7 + .../datasources/voice_call_ws_client.dart | 231 +++ .../data/models/voice_session_model.dart | 42 + .../fallback_voice_call_repository.dart | 41 + .../voice_call_repository_impl.dart | 18 + .../services/ai_audio_playback_service.dart | 298 +++ .../services/speech_recognition_service.dart | 257 +++ .../domain/entities/call_state.dart | 16 + .../domain/entities/voice_session_entity.dart | 13 + .../repositories/voice_call_repository.dart | 7 + .../controllers/ai_call_controller.dart | 538 ++++++ .../controllers/ai_call_state.dart | 91 + .../providers/ai_call_provider.dart | 40 + .../providers/voice_call_provider.dart | 23 + .../screens/voice_call_screen.dart | 1141 ++++++++++++ .../screens/voice_session_detail_screen.dart | 751 ++++++++ .../morphing_voice_nodes_visualizer.dart | 502 +++++ apps/eva_mobile/lib/main.dart | 41 + .../providers/daily_progress_provider.dart | 154 ++ .../lib/services/audio_recorder_service.dart | 47 + .../daily_progress_local_service.dart | 185 ++ apps/eva_mobile/lib/services/stt_service.dart | 136 ++ .../lib/services/vad_audio_service.dart | 215 +++ apps/eva_mobile/lib/theme/app_colors.dart | 50 + apps/eva_mobile/lib/theme/app_theme.dart | 57 + apps/eva_mobile/lib/theme/app_typography.dart | 106 ++ .../lib/widgets/app_back_button.dart | 77 + .../eva_mobile/lib/widgets/app_snack_bar.dart | 207 +++ .../eva_mobile/lib/widgets/custom_button.dart | 242 +++ .../lib/widgets/custom_text_field.dart | 117 ++ .../emergency_contact_bottom_sheet.dart | 549 ++++++ .../lib/widgets/eva_loading_orb.dart | 304 +++ .../lib/widgets/floating_nav_bar.dart | 276 +++ apps/eva_mobile/lib/widgets/glass_card.dart | 118 ++ .../lib/widgets/luna_loading_orb.dart | 2 + .../widgets/mood_checkin_bottom_sheet.dart | 797 ++++++++ .../lib/widgets/skeleton_shimmer.dart | 185 ++ .../lib/widgets/social_auth_dev_sheet.dart | 119 ++ .../lib/widgets/staggered_entrance.dart | 89 + .../lib/widgets/web_phone_frame_wrapper.dart | 215 +++ .../core/network/fallback_policy_test.dart | 59 + .../features/care/care_provider_test.dart | 165 ++ .../test/features/care/care_screen_test.dart | 103 ++ .../monitoring/monitoring_screen_test.dart | 2 +- .../tutorial/branded_guidance_card_test.dart | 70 + .../interactive_spotlight_overlay_test.dart | 94 + .../tutorial/main_shell_tutorial_test.dart | 83 + .../tutorial/tutorial_provider_test.dart | 104 ++ .../screens/ai_conversation_screen_test.dart | 41 +- .../screens/ai_diary_detail_screen_test.dart | 2 +- .../screens/dass_assessment_screen_test.dart | 2 +- .../test/screens/login_screen_test.dart | 48 + .../main_shell_and_call_screen_test.dart | 4 +- .../test/screens/settings_screen_test.dart | 2 +- .../splash_onboarding_screen_test.dart | 2 +- .../test/widgets/app_back_button_test.dart | 119 ++ .../test/widgets/app_snack_bar_test.dart | 125 ++ .../test/widgets/floating_nav_bar_test.dart | 10 +- .../test/widgets/home_screen_test.dart | 37 +- 192 files changed, 35106 insertions(+), 20 deletions(-) create mode 100644 apps/eva_mobile/lib/config/app_config.dart create mode 100644 apps/eva_mobile/lib/core/config/app_config.dart create mode 100644 apps/eva_mobile/lib/core/network/api_client.dart create mode 100644 apps/eva_mobile/lib/core/network/fallback_policy.dart create mode 100644 apps/eva_mobile/lib/core/routes/app_route_transitions.dart create mode 100644 apps/eva_mobile/lib/core/routes/app_router.dart create mode 100644 apps/eva_mobile/lib/core/storage/token_storage.dart create mode 100644 apps/eva_mobile/lib/core/utils/responsive_layout_helper.dart create mode 100644 apps/eva_mobile/lib/data/repositories/voice_call_repository.dart create mode 100644 apps/eva_mobile/lib/features/auth/data/datasources/auth_datasource.dart create mode 100644 apps/eva_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart create mode 100644 apps/eva_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart create mode 100644 apps/eva_mobile/lib/features/auth/data/models/emergency_contact_model.dart create mode 100644 apps/eva_mobile/lib/features/auth/data/models/user_model.dart create mode 100644 apps/eva_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart create mode 100644 apps/eva_mobile/lib/features/auth/data/repositories/fallback_auth_repository.dart create mode 100644 apps/eva_mobile/lib/features/auth/domain/entities/emergency_contact_entity.dart create mode 100644 apps/eva_mobile/lib/features/auth/domain/entities/user_entity.dart create mode 100644 apps/eva_mobile/lib/features/auth/domain/repositories/auth_repository.dart create mode 100644 apps/eva_mobile/lib/features/auth/presentation/providers/auth_provider.dart create mode 100644 apps/eva_mobile/lib/features/auth/presentation/screens/login_screen.dart create mode 100644 apps/eva_mobile/lib/features/auth/presentation/screens/profile_screen.dart create mode 100644 apps/eva_mobile/lib/features/auth/presentation/screens/register_screen.dart create mode 100644 apps/eva_mobile/lib/features/auth/presentation/screens/settings_screen.dart create mode 100644 apps/eva_mobile/lib/features/auth/presentation/screens/splash_onboarding_screen.dart create mode 100644 apps/eva_mobile/lib/features/auth/presentation/screens/terms_disclaimer_screen.dart create mode 100644 apps/eva_mobile/lib/features/care/data/datasources/care_datasource.dart create mode 100644 apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart create mode 100644 apps/eva_mobile/lib/features/care/data/datasources/remote_care_datasource.dart create mode 100644 apps/eva_mobile/lib/features/care/data/models/clinic_model.dart create mode 100644 apps/eva_mobile/lib/features/care/data/models/community_status_model.dart create mode 100644 apps/eva_mobile/lib/features/care/data/models/consultation_session_model.dart create mode 100644 apps/eva_mobile/lib/features/care/data/models/psychologist_model.dart create mode 100644 apps/eva_mobile/lib/features/care/data/repositories/care_repository_impl.dart create mode 100644 apps/eva_mobile/lib/features/care/data/repositories/fallback_care_repository.dart create mode 100644 apps/eva_mobile/lib/features/care/domain/entities/clinic_entity.dart create mode 100644 apps/eva_mobile/lib/features/care/domain/entities/community_status_entity.dart create mode 100644 apps/eva_mobile/lib/features/care/domain/entities/consultation_session_entity.dart create mode 100644 apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart create mode 100644 apps/eva_mobile/lib/features/care/domain/repositories/care_repository.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/providers/care_provider.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/screens/care_screen.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/booking_consultation_sheet.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/clinic_card.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/community_banner_card.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/community_paywall_sheet.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/consultation_history_card.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_card.dart create mode 100644 apps/eva_mobile/lib/features/chat/data/datasources/chat_datasource.dart create mode 100644 apps/eva_mobile/lib/features/chat/data/datasources/mock_chat_datasource.dart create mode 100644 apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart create mode 100644 apps/eva_mobile/lib/features/chat/data/models/chat_message_model.dart create mode 100644 apps/eva_mobile/lib/features/chat/data/models/conversation_model.dart create mode 100644 apps/eva_mobile/lib/features/chat/data/repositories/chat_repository_impl.dart create mode 100644 apps/eva_mobile/lib/features/chat/data/repositories/fallback_chat_repository.dart create mode 100644 apps/eva_mobile/lib/features/chat/domain/entities/chat_message_entity.dart create mode 100644 apps/eva_mobile/lib/features/chat/domain/entities/conversation_entity.dart create mode 100644 apps/eva_mobile/lib/features/chat/domain/repositories/chat_repository.dart create mode 100644 apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart create mode 100644 apps/eva_mobile/lib/features/chat/presentation/screens/ai_conversation_screen.dart create mode 100644 apps/eva_mobile/lib/features/chat/presentation/widgets/animated_session_card.dart create mode 100644 apps/eva_mobile/lib/features/chat/presentation/widgets/session_list_skeleton.dart create mode 100644 apps/eva_mobile/lib/features/dass/data/datasources/dass_datasource.dart create mode 100644 apps/eva_mobile/lib/features/dass/data/models/dass_assessment_model.dart create mode 100644 apps/eva_mobile/lib/features/dass/data/repositories/dass_repository_impl.dart create mode 100644 apps/eva_mobile/lib/features/dass/data/repositories/fallback_dass_repository.dart create mode 100644 apps/eva_mobile/lib/features/dass/domain/entities/dass_assessment_entity.dart create mode 100644 apps/eva_mobile/lib/features/dass/domain/repositories/dass_repository.dart create mode 100644 apps/eva_mobile/lib/features/dass/presentation/providers/dass_provider.dart create mode 100644 apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart create mode 100644 apps/eva_mobile/lib/features/dass/presentation/widgets/bouncing_button.dart create mode 100644 apps/eva_mobile/lib/features/dass/presentation/widgets/dass_ai_analysis_accordion.dart create mode 100644 apps/eva_mobile/lib/features/dass/presentation/widgets/dass_form_card.dart create mode 100644 apps/eva_mobile/lib/features/dass/presentation/widgets/dass_skeleton_loader.dart create mode 100644 apps/eva_mobile/lib/features/diary/data/datasources/diary_datasource.dart create mode 100644 apps/eva_mobile/lib/features/diary/data/datasources/mock_diary_datasource.dart create mode 100644 apps/eva_mobile/lib/features/diary/data/datasources/remote_diary_datasource.dart create mode 100644 apps/eva_mobile/lib/features/diary/data/models/diary_entry_model.dart create mode 100644 apps/eva_mobile/lib/features/diary/data/models/risk_warning_model.dart create mode 100644 apps/eva_mobile/lib/features/diary/data/repositories/diary_repository_impl.dart create mode 100644 apps/eva_mobile/lib/features/diary/data/repositories/fallback_diary_repository.dart create mode 100644 apps/eva_mobile/lib/features/diary/domain/entities/diary_entry_entity.dart create mode 100644 apps/eva_mobile/lib/features/diary/domain/entities/risk_warning_entity.dart create mode 100644 apps/eva_mobile/lib/features/diary/domain/repositories/diary_repository.dart create mode 100644 apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart create mode 100644 apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_detail_screen.dart create mode 100644 apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_screen.dart create mode 100644 apps/eva_mobile/lib/features/diary/presentation/widgets/summary_skeleton_loader.dart create mode 100644 apps/eva_mobile/lib/features/emergency/presentation/screens/emergency_contacts_screen.dart create mode 100644 apps/eva_mobile/lib/features/emergency/presentation/screens/support_emergency_screen.dart create mode 100644 apps/eva_mobile/lib/features/emergency/presentation/widgets/breathing_exercise_modal.dart create mode 100644 apps/eva_mobile/lib/features/emergency/presentation/widgets/contact_form_dialog.dart create mode 100644 apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_contacts_skeleton_loader.dart create mode 100644 apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_hotline_modal.dart create mode 100644 apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart create mode 100644 apps/eva_mobile/lib/features/home/presentation/screens/main_shell_screen.dart create mode 100644 apps/eva_mobile/lib/features/home/presentation/widgets/home_activity_modal.dart create mode 100644 apps/eva_mobile/lib/features/home/presentation/widgets/profile_dropdown_modal.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/data/datasources/mock_monitoring_datasource.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/data/datasources/monitoring_datasource.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/data/datasources/remote_monitoring_datasource.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/data/models/monitoring_data_model.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/data/repositories/fallback_monitoring_repository.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/data/repositories/monitoring_repository_impl.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/domain/entities/monitoring_data_entity.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/domain/repositories/monitoring_repository.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/presentation/providers/monitoring_provider.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/presentation/screens/monitoring_screen.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/presentation/widgets/monitoring_dass_card.dart create mode 100644 apps/eva_mobile/lib/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/data/datasources/mock_recommendation_datasource.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/data/datasources/recommendation_datasource.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/data/datasources/remote_recommendation_datasource.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/data/models/recommendation_model.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/data/repositories/fallback_recommendation_repository.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/data/repositories/recommendation_repository_impl.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/domain/entities/recommendation_entity.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/domain/repositories/recommendation_repository.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/presentation/providers/recommendation_provider.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/presentation/screens/recommendation_screen.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/presentation/widgets/guidance_modal_content.dart create mode 100644 apps/eva_mobile/lib/features/recommendations/presentation/widgets/recommendation_skeleton_loader.dart create mode 100644 apps/eva_mobile/lib/features/tutorial/data/repositories/tutorial_repository_impl.dart create mode 100644 apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart create mode 100644 apps/eva_mobile/lib/features/tutorial/domain/repositories/tutorial_repository.dart create mode 100644 apps/eva_mobile/lib/features/tutorial/presentation/providers/tutorial_provider.dart create mode 100644 apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart create mode 100644 apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/config/call_config.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/data/datasources/mock_voice_call_datasource.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/data/datasources/remote_voice_call_datasource.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/data/datasources/voice_call_datasource.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/data/datasources/voice_call_ws_client.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/data/models/voice_session_model.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/data/repositories/fallback_voice_call_repository.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/data/repositories/voice_call_repository_impl.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/data/services/ai_audio_playback_service.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/data/services/speech_recognition_service.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/domain/entities/call_state.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/domain/entities/voice_session_entity.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/domain/repositories/voice_call_repository.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/presentation/controllers/ai_call_controller.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/presentation/controllers/ai_call_state.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/presentation/providers/ai_call_provider.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/presentation/providers/voice_call_provider.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_call_screen.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_session_detail_screen.dart create mode 100644 apps/eva_mobile/lib/features/voice_call/presentation/widgets/morphing_voice_nodes_visualizer.dart create mode 100644 apps/eva_mobile/lib/main.dart create mode 100644 apps/eva_mobile/lib/providers/daily_progress_provider.dart create mode 100644 apps/eva_mobile/lib/services/audio_recorder_service.dart create mode 100644 apps/eva_mobile/lib/services/daily_progress_local_service.dart create mode 100644 apps/eva_mobile/lib/services/stt_service.dart create mode 100644 apps/eva_mobile/lib/services/vad_audio_service.dart create mode 100644 apps/eva_mobile/lib/theme/app_colors.dart create mode 100644 apps/eva_mobile/lib/theme/app_theme.dart create mode 100644 apps/eva_mobile/lib/theme/app_typography.dart create mode 100644 apps/eva_mobile/lib/widgets/app_back_button.dart create mode 100644 apps/eva_mobile/lib/widgets/app_snack_bar.dart create mode 100644 apps/eva_mobile/lib/widgets/custom_button.dart create mode 100644 apps/eva_mobile/lib/widgets/custom_text_field.dart create mode 100644 apps/eva_mobile/lib/widgets/emergency_contact_bottom_sheet.dart create mode 100644 apps/eva_mobile/lib/widgets/eva_loading_orb.dart create mode 100644 apps/eva_mobile/lib/widgets/floating_nav_bar.dart create mode 100644 apps/eva_mobile/lib/widgets/glass_card.dart create mode 100644 apps/eva_mobile/lib/widgets/luna_loading_orb.dart create mode 100644 apps/eva_mobile/lib/widgets/mood_checkin_bottom_sheet.dart create mode 100644 apps/eva_mobile/lib/widgets/skeleton_shimmer.dart create mode 100644 apps/eva_mobile/lib/widgets/social_auth_dev_sheet.dart create mode 100644 apps/eva_mobile/lib/widgets/staggered_entrance.dart create mode 100644 apps/eva_mobile/lib/widgets/web_phone_frame_wrapper.dart create mode 100644 apps/eva_mobile/test/core/network/fallback_policy_test.dart create mode 100644 apps/eva_mobile/test/features/care/care_provider_test.dart create mode 100644 apps/eva_mobile/test/features/care/care_screen_test.dart create mode 100644 apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart create mode 100644 apps/eva_mobile/test/features/tutorial/interactive_spotlight_overlay_test.dart create mode 100644 apps/eva_mobile/test/features/tutorial/main_shell_tutorial_test.dart create mode 100644 apps/eva_mobile/test/features/tutorial/tutorial_provider_test.dart create mode 100644 apps/eva_mobile/test/screens/login_screen_test.dart create mode 100644 apps/eva_mobile/test/widgets/app_back_button_test.dart create mode 100644 apps/eva_mobile/test/widgets/app_snack_bar_test.dart diff --git a/.gitignore b/.gitignore index 1eb150f..356ad65 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ eggs/ .eggs/ lib/ !apps/luna_mobile/lib/ +!apps/eva_mobile/lib/ lib64/ parts/ sdist/ diff --git a/apps/eva_mobile/AGENTS.md b/apps/eva_mobile/AGENTS.md index 719fb4f..eefaff7 100644 --- a/apps/eva_mobile/AGENTS.md +++ b/apps/eva_mobile/AGENTS.md @@ -32,3 +32,13 @@ - Multi-step interactive modals or bottom sheets. - Reusable loaders (skeleton shimmer) and buttons. - Place extracted widgets into `features//presentation/widgets/`. + +## 5. Component Reuse & Design System Invariant +- **Reuse Existing Components First**: Before building custom UI elements or writing inline styling, ALWAYS check and reuse standard widgets in `lib/widgets/`: + - `AppBackButton`: Standardized frosted circular back button across all screens. + - `GlassCard`: Frosted glass container with rounded corners and gradient borders. + - `AppSnackBar`: Standardized snackbar notification banners. + - `SkeletonShimmer`: Standardized loading placeholder animations. + - `CustomButton` & `CustomTextField`: Standardized input elements. + - `StaggeredEntrance`: Fluid entrance animation wrapper. +- **No Inline Duplication**: Do not declare manual inline back buttons (`IconButton`, custom `BoxShape.circle`, etc.) directly inside screens. Always consume `AppBackButton`. diff --git a/apps/eva_mobile/lib/config/app_config.dart b/apps/eva_mobile/lib/config/app_config.dart new file mode 100644 index 0000000..1514ce7 --- /dev/null +++ b/apps/eva_mobile/lib/config/app_config.dart @@ -0,0 +1,303 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppConfig { + /// Compile-time mock flag: --dart-define=USE_MOCK_DATA=true or --dart-define=MOCK=true + static bool useMockData = const bool.fromEnvironment( + 'USE_MOCK_DATA', + defaultValue: bool.fromEnvironment('MOCK', defaultValue: false), + ); + + /// Compile-time hybrid fallback flag: --dart-define=USE_HYBRID_FALLBACK=true or --dart-define=HYBRID=true + static const bool useHybridFallback = bool.fromEnvironment( + 'USE_HYBRID_FALLBACK', + defaultValue: bool.fromEnvironment('HYBRID', defaultValue: false), + ); + + /// Fast failover timeout for hybrid fallback requests to keep UI responsive + static const Duration hybridTimeout = Duration(seconds: 3); + static String? authToken; + static String? _cachedRefreshToken; + static String? _customHost; + + static String? _cachedUserName; + static String? _cachedUserEmail; + + /// Compile-time environment flag: --dart-define=USE_LOCAL_API=true or --dart-define=LOCAL=true or --dart-define=USE_LOCALHOST=true + static const bool useLocalApi = bool.fromEnvironment( + 'USE_LOCAL_API', + defaultValue: bool.fromEnvironment( + 'LOCAL', + defaultValue: bool.fromEnvironment('USE_LOCALHOST', 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 API base URL: --dart-define=API_BASE_URL=http://10.0.2.2:8888/api/v1 + static const String customBaseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: String.fromEnvironment('BASE_URL', defaultValue: ''), + ); + + /// Optional compile-time custom host: --dart-define=LOCAL_HOST=192.168.1.100:8888 + static const String customLocalHost = String.fromEnvironment( + 'LOCAL_HOST', + 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'; + + /// Default seeded demo credentials for quick access and testing + static const String demoEmail = 'samsul@gmail.com'; + static const String demoPassword = 'password123'; + + /// Get the active host (IP or domain). + /// Respects --dart-define=USE_LOCAL_API=true when running locally. + static String get host { + if (_customHost != null && _customHost!.isNotEmpty) { + return _customHost!; + } + if (customBaseUrl.isNotEmpty) { + final uri = Uri.tryParse(customBaseUrl); + if (uri != null && uri.host.isNotEmpty) { + return uri.hasPort ? '${uri.host}:${uri.port}' : uri.host; + } + } + if (customLocalHost.isNotEmpty) { + return customLocalHost; + } + 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; + if (customBaseUrl.startsWith('http://')) 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 (customBaseUrl.isNotEmpty) { + return customBaseUrl; + } + 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); + } + + /// Get cached user phone number + static Future getUserPhone() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('user_phone') ?? '+62 812-3456-7890'; + } + + /// Get cached user gender + static Future getUserGender() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('user_gender') ?? 'Laki-laki'; + } + + /// Get cached user birth date + static Future getUserBirthDate() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('user_birth_date') ?? '18 Agustus 2002'; + } + + /// Save detailed user profile data + static Future setUserProfileDetails({ + required String name, + required String email, + String? phone, + String? gender, + String? birthDate, + }) async { + await setUserInfo(name: name, email: email); + final prefs = await SharedPreferences.getInstance(); + if (phone != null) await prefs.setString('user_phone', phone); + if (gender != null) await prefs.setString('user_gender', gender); + if (birthDate != null) await prefs.setString('user_birth_date', birthDate); + } + + /// Clear token and profile info (Logout) + static Future 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..d40c1f7 --- /dev/null +++ b/apps/eva_mobile/lib/core/network/api_client.dart @@ -0,0 +1,95 @@ +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(); + + Duration get _effectiveTimeout => + AppConfig.useHybridFallback ? AppConfig.hybridTimeout : const Duration(seconds: 10); + + 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).timeout(_effectiveTimeout); + 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, + ).timeout(_effectiveTimeout); + 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, + ).timeout(_effectiveTimeout); + 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).timeout(_effectiveTimeout); + 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/network/fallback_policy.dart b/apps/eva_mobile/lib/core/network/fallback_policy.dart new file mode 100644 index 0000000..e152bcb --- /dev/null +++ b/apps/eva_mobile/lib/core/network/fallback_policy.dart @@ -0,0 +1,84 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'api_client.dart'; + +/// Policy governing whether a failed remote operation should gracefully fallback to mock data. +class FallbackPolicy { + /// Evaluates whether an error indicates an unavailable backend or missing endpoint, + /// qualifying it for a seamless fallback to Mock DataSource. + /// + /// Criteria: + /// - TRUE for: SocketException, TimeoutException, connection refused, host lookup failures, + /// or HTTP 404 (Not Found) / 501 (Not Implemented). + /// - FALSE for: HTTP 401 (Unauthorized), 403 (Forbidden), 422 (Unprocessable), + /// or 500 (Internal Server Error) to avoid masking actual business bugs. + static bool shouldFallback(Object error) { + if (error is SocketException || error is TimeoutException) { + return true; + } + if (error is ApiException) { + final code = error.statusCode; + if (code == 404 || code == 501 || code == null) { + return true; + } + return false; // Real errors (401, 403, 422, 500) must propagate + } + final msg = error.toString().toLowerCase(); + if (msg.contains('connection refused') || + msg.contains('failed host lookup') || + msg.contains('network is unreachable') || + msg.contains('failed to connect') || + msg.contains('timed out')) { + return true; + } + return false; + } +} + +/// Helper utility for coordinating remote execution with transparent fallback to mock data. +class FallbackExecutor { + /// Executes [remoteCall]. If an error occurs that satisfies [FallbackPolicy.shouldFallback], + /// logs a prominent warning and returns the result of [mockCall]. + /// Otherwise, rethrows the original error. + static Future execute({ + required Future Function() remoteCall, + required Future Function() mockCall, + required String operationName, + }) async { + try { + return await remoteCall(); + } catch (e) { + if (FallbackPolicy.shouldFallback(e)) { + debugPrint('⚠️ [HYBRID-FALLBACK] $operationName gagal ($e). Beralih ke Mock DataSource.'); + return await mockCall(); + } + rethrow; + } + } + + /// Executes a streaming [remoteStreamCall]. If it fails with a fallback-eligible error, + /// seamlessly yields events from [mockStreamCall]. + static Stream executeStream({ + required Stream Function() remoteStreamCall, + required Stream Function() mockStreamCall, + required String operationName, + }) async* { + bool hasYielded = false; + try { + await for (final event in remoteStreamCall()) { + hasYielded = true; + yield event; + } + } catch (e) { + if (!hasYielded && FallbackPolicy.shouldFallback(e)) { + debugPrint('⚠️ [HYBRID-FALLBACK] Stream $operationName gagal ($e). Beralih ke Mock Stream.'); + await for (final event in mockStreamCall()) { + yield event; + } + } else { + rethrow; + } + } + } +} diff --git a/apps/eva_mobile/lib/core/routes/app_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..7663006 --- /dev/null +++ b/apps/eva_mobile/lib/core/routes/app_router.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; + +import 'package:eva_mobile/features/auth/presentation/screens/login_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/register_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/profile_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/settings_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/terms_disclaimer_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/splash_onboarding_screen.dart'; +import 'package:eva_mobile/features/chat/presentation/screens/ai_conversation_screen.dart'; +import 'package:eva_mobile/features/dass/presentation/screens/dass_assessment_screen.dart'; +import 'package:eva_mobile/features/diary/presentation/screens/ai_diary_screen.dart'; +import 'package:eva_mobile/features/diary/presentation/screens/ai_diary_detail_screen.dart'; +import 'package:eva_mobile/features/emergency/presentation/screens/emergency_contacts_screen.dart'; +import 'package:eva_mobile/features/emergency/presentation/screens/support_emergency_screen.dart'; +import 'package:eva_mobile/features/home/presentation/screens/main_shell_screen.dart'; +import 'package:eva_mobile/features/monitoring/presentation/screens/monitoring_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/care_screen.dart'; +import 'package:eva_mobile/features/recommendations/presentation/screens/recommendation_screen.dart'; +import 'package:eva_mobile/features/voice_call/presentation/screens/voice_call_screen.dart'; +import 'package:eva_mobile/features/voice_call/presentation/screens/voice_session_detail_screen.dart'; +import 'app_route_transitions.dart'; + +/// Centralized route generator applying custom transitions to all Eva Mobile pages. +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 '/care': + return AppRouteTransitions.fadeThroughRoute( + page: const CareScreen(), + settings: settings, + ); + case '/user_profile': + case '/profile': + return AppRouteTransitions.horizontalDetailRoute( + 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..d0687e5 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/datasources/auth_datasource.dart @@ -0,0 +1,13 @@ +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 checkEmailAvailability(String email); + 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..3bb094d --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart @@ -0,0 +1,102 @@ +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 checkEmailAvailability(String email) async { + await Future.delayed(const Duration(milliseconds: 200)); + return !email.contains('taken'); + } + + @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..f4709ad --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart @@ -0,0 +1,106 @@ +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; + final refreshToken = response['refresh_token'] as String?; + AppConfig.authToken = token; + if (refreshToken != null) { + await AppConfig.setTokens(accessToken: token, refreshToken: refreshToken); + } else { + await AppConfig.setToken(token); + } + await TokenStorage.saveToken(token); + } + final userData = response['user'] as Map? ?? {}; + final user = UserModel.fromJson(userData); + await AppConfig.setUserInfo(name: user.name, email: user.email); + return 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; + final refreshToken = response['refresh_token'] as String?; + AppConfig.authToken = token; + if (refreshToken != null) { + await AppConfig.setTokens(accessToken: token, refreshToken: refreshToken); + } else { + await AppConfig.setToken(token); + } + await TokenStorage.saveToken(token); + } + final userData = response['user'] as Map? ?? {}; + final user = UserModel.fromJson(userData); + await AppConfig.setUserInfo(name: user.name, email: user.email); + return user; + } + + @override + Future checkEmailAvailability(String email) async { + try { + final response = await _apiClient.get('/auth/check-email', queryParams: {'email': email}); + if (response is Map && response['available'] != null) { + return response['available'] == true; + } + return true; + } catch (_) { + return true; + } + } + + @override + 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..4a36f4b --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,44 @@ +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 checkEmailAvailability(String email) => _dataSource.checkEmailAvailability(email); + + @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/data/repositories/fallback_auth_repository.dart b/apps/eva_mobile/lib/features/auth/data/repositories/fallback_auth_repository.dart new file mode 100644 index 0000000..ca30afa --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/data/repositories/fallback_auth_repository.dart @@ -0,0 +1,87 @@ +import '../../domain/repositories/auth_repository.dart'; +import '../../domain/entities/user_entity.dart'; +import '../../domain/entities/emergency_contact_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackAuthRepository implements AuthRepository { + final AuthRepository _remote; + final AuthRepository _mock; + + FallbackAuthRepository({ + required AuthRepository remote, + required AuthRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future getCurrentUser() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getCurrentUser(), + mockCall: () => _mock.getCurrentUser(), + operationName: 'getCurrentUser', + ); + } + + @override + Future login(String email, String password) { + return FallbackExecutor.execute( + remoteCall: () => _remote.login(email, password), + mockCall: () => _mock.login(email, password), + operationName: 'login', + ); + } + + @override + Future register(String name, String email, String password) { + return FallbackExecutor.execute( + remoteCall: () => _remote.register(name, email, password), + mockCall: () => _mock.register(name, email, password), + operationName: 'register', + ); + } + + @override + Future checkEmailAvailability(String email) { + return FallbackExecutor.execute( + remoteCall: () => _remote.checkEmailAvailability(email), + mockCall: () => _mock.checkEmailAvailability(email), + operationName: 'checkEmailAvailability', + ); + } + + @override + Future logout() { + return FallbackExecutor.execute( + remoteCall: () => _remote.logout(), + mockCall: () => _mock.logout(), + operationName: 'logout', + ); + } + + @override + Future> getEmergencyContacts() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getEmergencyContacts(), + mockCall: () => _mock.getEmergencyContacts(), + operationName: 'getEmergencyContacts', + ); + } + + @override + Future addEmergencyContact(EmergencyContactEntity contact) { + return FallbackExecutor.execute( + remoteCall: () => _remote.addEmergencyContact(contact), + mockCall: () => _mock.addEmergencyContact(contact), + operationName: 'addEmergencyContact', + ); + } + + @override + Future deleteEmergencyContact(String id) { + return FallbackExecutor.execute( + remoteCall: () => _remote.deleteEmergencyContact(id), + mockCall: () => _mock.deleteEmergencyContact(id), + operationName: 'deleteEmergencyContact', + ); + } +} diff --git a/apps/eva_mobile/lib/features/auth/domain/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..00b5987 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,13 @@ +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 checkEmailAvailability(String email); + 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..6fbbf26 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/providers/auth_provider.dart @@ -0,0 +1,32 @@ +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'; +import '../../data/repositories/fallback_auth_repository.dart'; + +final authRepositoryProvider = Provider((ref) { + final mockRepo = AuthRepositoryImpl(MockAuthDataSource()); + final remoteRepo = AuthRepositoryImpl(RemoteAuthDataSource()); + + if (AppConfig.useMockData) { + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackAuthRepository(remote: remoteRepo, mock: mockRepo); + } else { + return remoteRepo; + } +}); + +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/auth/presentation/screens/login_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/login_screen.dart new file mode 100644 index 0000000..cdc344d --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/login_screen.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/custom_text_field.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/social_auth_dev_sheet.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + void _fillDemoAccount() { + setState(() { + _emailController.text = AppConfig.demoEmail; + _passwordController.text = AppConfig.demoPassword; + }); + AppSnackBar.showPrimary( + context, + 'Akun demo berhasil diisi (${AppConfig.demoEmail})', + icon: Icons.check_circle_outline, + duration: const Duration(seconds: 2), + ); + } + + Future _handleLogin() async { + final email = _emailController.text.trim().toLowerCase(); + final password = _passwordController.text.trim(); + + if (email.isEmpty || password.isEmpty) { + AppSnackBar.showWarning(context, 'Silakan masukkan email dan kata sandi'); + return; + } + + setState(() => _isLoading = true); + + try { + final authRepo = ref.read(authRepositoryProvider); + final user = await authRepo.login(email, password); + + if (!mounted) return; + AppSnackBar.showPrimary( + context, + 'Selamat datang kembali, ${user.name}!', + icon: Icons.check_circle_outline, + duration: const Duration(seconds: 2), + ); + Navigator.pushReplacementNamed(context, '/home'); + } catch (e) { + if (!mounted) return; + AppSnackBar.showError( + context, + 'Gagal masuk: ${e.toString().replaceAll('ApiException: ', '')}', + duration: const Duration(seconds: 4), + ); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF3F5FF), + Color(0xFFE9ECFF), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Brand Logo + Image.asset( + 'assets/images/eva_logo.png', + width: 50, + height: 50, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.nightlight_round, + size: 44, + color: AppColors.primary, + ); + }, + ), + const SizedBox(height: 8), + + Text( + 'Selamat Datang Kembali', + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + + Text( + 'Masuk untuk melanjutkan perjalanan ketenanganmu', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 28), + + // Email Field + CustomPillTextField( + controller: _emailController, + hintText: 'Alamat Email', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 14), + + // Password Field + CustomPillTextField( + controller: _passwordController, + hintText: 'Kata Sandi', + prefixIcon: Icons.lock_outline, + isPassword: true, + ), + const SizedBox(height: 10), + + // Demo Account Autofill Button + Align( + alignment: Alignment.centerRight, + child: Material( + color: Colors.transparent, + child: InkWell( + key: const Key('login_demo_autofill_button'), + borderRadius: BorderRadius.circular(6), + onTap: _fillDemoAccount, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.auto_awesome_rounded, + size: 14, + color: AppColors.primary, + ), + const SizedBox(width: 4), + Text( + 'Gunakan Akun Demo', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 24), + + // Login Button + _isLoading + ? const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ) + : CustomPillButton( + text: 'Masuk', + onPressed: _handleLogin, + ), + const SizedBox(height: 20), + + // Or Divider + const Row( + children: [ + Expanded(child: Divider(color: Color(0xFFD6DCF5))), + Padding( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + 'atau masuk dengan', + style: TextStyle( + fontSize: 12, + color: AppColors.textLight, + ), + ), + ), + Expanded(child: Divider(color: Color(0xFFD6DCF5))), + ], + ), + const SizedBox(height: 20), + + // Social Login Buttons Row + Row( + children: [ + Expanded( + child: SocialPillButton( + type: 'google', + onPressed: () { + showSocialAuthDevSheet(context, provider: 'Google'); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SocialPillButton( + type: 'apple', + onPressed: () { + showSocialAuthDevSheet(context, provider: 'Apple'); + }, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Register Link Footer + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Belum punya akun? ', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + GestureDetector( + onTap: () { + Navigator.pushReplacementNamed(context, '/register'); + }, + child: Text( + 'Daftar', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), +); + } +} diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/profile_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/profile_screen.dart new file mode 100644 index 0000000..f220a90 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/profile_screen.dart @@ -0,0 +1,994 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import '../providers/auth_provider.dart'; + +class ProfileScreen extends ConsumerStatefulWidget { + const ProfileScreen({super.key}); + + @override + ConsumerState createState() => ProfileScreenState(); +} + +class ProfileScreenState extends ConsumerState { + String _userName = 'Pengguna EVA'; + String _userEmail = ''; + String _userPhone = '+62 812-3456-7890'; + String _userGender = 'Laki-laki'; + String _userBirthDate = '18 Agustus 2002'; + final String _userLocation = 'Jakarta, Indonesia'; + String? _emergencyContactName; + String? _emergencyContactPhone; + + void refresh() { + _loadProfileData(); + } + + String get _userInitials { + final parts = _userName.trim().split(RegExp(r'\s+')); + if (parts.length >= 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } else if (parts.isNotEmpty && parts[0].isNotEmpty) { + return parts[0].substring(0, parts[0].length >= 2 ? 2 : 1).toUpperCase(); + } + return 'LU'; + } + + @override + void initState() { + super.initState(); + _loadProfileData(); + } + + Future _loadProfileData() async { + // 1. Cached profile info + final cachedName = await AppConfig.getUserName(); + final cachedEmail = await AppConfig.getUserEmail(); + final cachedPhone = await AppConfig.getUserPhone(); + final cachedGender = await AppConfig.getUserGender(); + final cachedBirthDate = await AppConfig.getUserBirthDate(); + + if (mounted) { + setState(() { + if (cachedName != null && cachedName.isNotEmpty) { + _userName = cachedName; + } + if (cachedEmail != null && cachedEmail.isNotEmpty) { + _userEmail = cachedEmail; + } else { + _userEmail = AppConfig.demoEmail; + } + if (cachedPhone != null && cachedPhone.isNotEmpty) { + _userPhone = cachedPhone; + } + if (cachedGender != null && cachedGender.isNotEmpty) { + _userGender = cachedGender; + } + if (cachedBirthDate != null && cachedBirthDate.isNotEmpty) { + _userBirthDate = cachedBirthDate; + } + }); + } + + // 2. Fetch /auth/me via AuthRepository + try { + final user = await ref.read(authRepositoryProvider).getCurrentUser(); + if (user != null && mounted) { + setState(() { + _userName = user.name; + _userEmail = user.email; + }); + await AppConfig.setUserInfo(name: user.name, email: user.email); + } + } catch (_) {} + + // 3. Fetch primary emergency contact via AuthRepository + try { + final contacts = await ref.read(authRepositoryProvider).getEmergencyContacts(); + if (mounted) { + if (contacts.isNotEmpty) { + final primary = contacts.firstWhere( + (c) => c.isPrimary, + orElse: () => contacts.first, + ); + setState(() { + final rel = primary.relationship.isNotEmpty ? ' (${primary.relationship})' : ''; + _emergencyContactName = '${primary.name}$rel'; + _emergencyContactPhone = primary.phone; + }); + } else { + setState(() { + _emergencyContactName = null; + _emergencyContactPhone = null; + }); + } + } + } catch (_) {} + } + + void _showEditProfileModal() { + final nameCtrl = TextEditingController(text: _userName); + final phoneCtrl = TextEditingController(text: _userPhone); + String selectedGender = _userGender; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (modalCtx) { + return StatefulBuilder( + builder: (ctx, setModalState) { + return Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, + top: 20, + left: 20, + right: 20, + ), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Edit Profil Pengguna', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, size: 20), + onPressed: () => Navigator.pop(modalCtx), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Nama Lengkap', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 6), + TextField( + controller: nameCtrl, + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFFF8F9FE), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + prefixIcon: const Icon( + Icons.person_outline_rounded, + size: 20, + color: AppColors.primary, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + ), + const SizedBox(height: 14), + Text( + 'Nomor Telepon', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 6), + TextField( + controller: phoneCtrl, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + filled: true, + fillColor: const Color(0xFFF8F9FE), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + prefixIcon: const Icon( + Icons.phone_outlined, + size: 20, + color: AppColors.primary, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + ), + const SizedBox(height: 14), + Text( + 'Jenis Kelamin', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 8), + Row( + children: ['Laki-laki', 'Perempuan'].map((g) { + final isSel = selectedGender == g; + return Padding( + padding: const EdgeInsets.only(right: 10), + child: ChoiceChip( + label: Text(g), + selected: isSel, + selectedColor: AppColors.primaryContainer, + labelStyle: GoogleFonts.inter( + color: isSel ? AppColors.primary : AppColors.textSecondary, + fontWeight: isSel ? FontWeight.w700 : FontWeight.w500, + ), + onSelected: (_) { + setModalState(() => selectedGender = g); + }, + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + onPressed: () async { + final newName = nameCtrl.text.trim(); + final newPhone = phoneCtrl.text.trim(); + if (newName.isEmpty) return; + + await AppConfig.setUserProfileDetails( + name: newName, + email: _userEmail, + phone: newPhone, + gender: selectedGender, + ); + + if (mounted) { + setState(() { + _userName = newName; + _userPhone = newPhone; + _userGender = selectedGender; + }); + } + + if (modalCtx.mounted) { + Navigator.pop(modalCtx); + } + if (mounted) { + AppSnackBar.showSuccess(context, 'Profil berhasil diperbarui!'); + } + }, + child: Text( + 'Simpan Perubahan', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + void _showDeleteMemoryConfirmationDialog() { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text( + 'Hapus Memori AI?', + style: GoogleFonts.inter( + fontWeight: FontWeight.w800, + fontSize: 18, + color: AppColors.textPrimary, + ), + ), + content: Text( + 'Apakah Anda yakin ingin menghapus seluruh catatan memori AI? EVA akan menghapus riwayat konteks emosional yang telah dipelajari.', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Batal', + style: GoogleFonts.inter( + fontWeight: FontWeight.w700, + color: AppColors.textLight, + ), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD32F2F), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () { + Navigator.pop(context); + AppSnackBar.showSuccess( + context, + 'Seluruh catatan memori AI berhasil dihapus.', + ); + }, + child: Text( + 'Hapus', + style: GoogleFonts.inter( + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final canPop = Navigator.canPop(context); + + return Scaffold( + backgroundColor: AppColors.background, + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20.0, 16.0, 20.0, 100.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Back Button Navigation Header (only if pushed via route) + if (canPop) ...[ + Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Text( + 'Profil Pengguna', + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + ], + + // 1. User Profile Header Card + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Stack( + children: [ + Container( + width: 68, + height: 68, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [Color(0xFF8B93FF), Color(0xFF5358CB)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Center( + child: Text( + _userInitials, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: () { + AppSnackBar.show( + context, + message: 'Fitur penggantian foto profil akan segera hadir.', + ); + }, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: const Icon( + Icons.camera_alt_rounded, + size: 12, + color: Colors.white, + ), + ), + ), + ), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _userName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + _userEmail.isNotEmpty ? _userEmail : AppConfig.demoEmail, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'ANGGOTA PREMIUM PRO', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 0.5, + ), + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 18), + + // 2. Wellness Activity Summary Overview + Row( + children: [ + _buildStatCard( + icon: Icons.mic_none_rounded, + title: '14 Sesi', + subtitle: 'Sesi Curhat Suara', + ), + const SizedBox(width: 10), + _buildStatCard( + icon: Icons.auto_stories_outlined, + title: '28 Entri', + subtitle: 'Refleksi Jurnal', + ), + const SizedBox(width: 10), + _buildStatCard( + icon: Icons.sentiment_satisfied_alt_rounded, + title: 'Stabil', + subtitle: 'Status Emosi', + ), + ], + ), + + const SizedBox(height: 24), + + // 3. Section: INFORMASI PRIBADI + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'INFORMASI PRIBADI', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + GestureDetector( + onTap: _showEditProfileModal, + child: Row( + children: [ + const Icon( + Icons.edit_outlined, + size: 14, + color: AppColors.primary, + ), + const SizedBox(width: 4), + Text( + 'Edit Profil', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + children: [ + _buildInfoRow( + icon: Icons.person_outline_rounded, + label: 'Nama Lengkap', + value: _userName, + ), + const Divider(height: 16, thickness: 0.6, color: Color(0xFFF1F5F9)), + _buildInfoRow( + icon: Icons.mail_outline_rounded, + label: 'Alamat Email', + value: _userEmail.isNotEmpty ? _userEmail : AppConfig.demoEmail, + badge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFECFDF5), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFA7F3D0)), + ), + child: Text( + 'Terverifikasi', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF059669), + ), + ), + ), + ), + const Divider(height: 16, thickness: 0.6, color: Color(0xFFF1F5F9)), + _buildInfoRow( + icon: Icons.phone_outlined, + label: 'Nomor Telepon', + value: _userPhone, + ), + const Divider(height: 16, thickness: 0.6, color: Color(0xFFF1F5F9)), + _buildInfoRow( + icon: Icons.cake_outlined, + label: 'Tanggal Lahir', + value: _userBirthDate, + ), + const Divider(height: 16, thickness: 0.6, color: Color(0xFFF1F5F9)), + _buildInfoRow( + icon: Icons.face_outlined, + label: 'Jenis Kelamin', + value: _userGender, + ), + const Divider(height: 16, thickness: 0.6, color: Color(0xFFF1F5F9)), + _buildInfoRow( + icon: Icons.location_on_outlined, + label: 'Domisili', + value: _userLocation, + ), + ], + ), + ), + + const SizedBox(height: 24), + + // 4. Section: AI & PRIVASI + Text( + 'AI & PRIVASI MEMORI', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFE2DAFF), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.psychology_outlined, + color: AppColors.primary, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Memori Jangka Panjang AI', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + 'EVA menyimpan konteks emosional harianmu.', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + CustomPillButton( + text: 'Hapus Catatan Memori AI', + isOutline: true, + height: 44, + onPressed: _showDeleteMemoryConfirmationDialog, + ), + ], + ), + ), + + const SizedBox(height: 24), + + // 5. Section: RENCANA KESELAMATAN KRISIS + Text( + 'RENCANA KESELAMATAN KRISIS', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: Colors.transparent, + child: ListTile( + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFFFDCDD), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.contact_phone_outlined, + color: Color(0xFFD32F2F), + size: 20, + ), + ), + title: Text( + 'Kontak Darurat Utama', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + subtitle: Text( + _emergencyContactName != null + ? '$_emergencyContactName • ${_emergencyContactPhone ?? ''}' + : 'Belum diatur • Ketuk untuk menambah', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + color: _emergencyContactName != null + ? AppColors.textSecondary + : AppColors.textLight, + ), + ), + trailing: const Icon( + Icons.arrow_forward_ios, + size: 16, + color: AppColors.textLight, + ), + onTap: () async { + await Navigator.pushNamed(context, '/emergency_contacts'); + if (mounted) { + _loadProfileData(); + } + }, + ), + ), + ), + + const SizedBox(height: 24), + + // 6. Section: PENGATURAN APLIKASI + Text( + 'PENGATURAN APLIKASI', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: Colors.transparent, + child: ListTile( + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.tune_rounded, + color: AppColors.primary, + size: 20, + ), + ), + title: Text( + 'Pengaturan & Preferensi', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + subtitle: Text( + 'Suara AI, pengingat harian, privasi & sistem', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + trailing: const Icon( + Icons.arrow_forward_ios, + size: 16, + color: AppColors.textLight, + ), + onTap: () => Navigator.pushNamed(context, '/settings'), + ), + ), + ), + + const SizedBox(height: 36), + + // 7. Logout Pill Button + SizedBox( + width: double.infinity, + height: 52, + child: OutlinedButton.icon( + onPressed: () async { + await AppConfig.clearToken(); + if (context.mounted) { + Navigator.pushNamedAndRemoveUntil(context, '/login', (route) => false); + } + }, + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFD32F2F), width: 1.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + ), + backgroundColor: Colors.transparent, + ), + icon: const Icon( + Icons.logout, + color: Color(0xFFD32F2F), + size: 20, + ), + label: Text( + 'Keluar Akun', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w700, + color: const Color(0xFFD32F2F), + ), + ), + ), + ), + + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + Widget _buildStatCard({ + required IconData icon, + required String title, + required String subtitle, + }) { + return Expanded( + child: GlassCard( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 14), + child: Column( + children: [ + Icon(icon, size: 22, color: AppColors.primary), + const SizedBox(height: 6), + Text( + title, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoRow({ + required IconData icon, + required String label, + required String value, + Widget? badge, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 16, color: AppColors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textLight, + ), + ), + const SizedBox(height: 1), + Text( + value, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ?badge, + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/register_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/register_screen.dart new file mode 100644 index 0000000..0eb3531 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/register_screen.dart @@ -0,0 +1,439 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/custom_text_field.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/social_auth_dev_sheet.dart'; + +class RegisterScreen extends ConsumerStatefulWidget { + const RegisterScreen({super.key}); + + @override + ConsumerState createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends ConsumerState { + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + bool _agreeTerms = false; + bool _isLoading = false; + + Timer? _debounceTimer; + bool _isCheckingEmail = false; + bool? _isEmailAvailable; + String? _emailStatusMessage; + + @override + void dispose() { + _debounceTimer?.cancel(); + _nameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + void _onEmailChanged(String val) { + _debounceTimer?.cancel(); + final cleanEmail = val.trim().toLowerCase(); + + if (cleanEmail.isEmpty) { + setState(() { + _isCheckingEmail = false; + _isEmailAvailable = null; + _emailStatusMessage = null; + }); + return; + } + + final emailRegExp = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); + if (!emailRegExp.hasMatch(cleanEmail)) { + setState(() { + _isCheckingEmail = false; + _isEmailAvailable = false; + _emailStatusMessage = 'Format email belum valid'; + }); + return; + } + + setState(() { + _isCheckingEmail = true; + _isEmailAvailable = null; + _emailStatusMessage = 'Memeriksa ketersediaan email...'; + }); + + _debounceTimer = Timer(const Duration(milliseconds: 500), () async { + try { + final available = await ref.read(authRepositoryProvider).checkEmailAvailability(cleanEmail); + if (!mounted) return; + + final msg = available ? 'Email tersedia' : 'Email sudah terdaftar'; + setState(() { + _isCheckingEmail = false; + _isEmailAvailable = available; + _emailStatusMessage = msg; + }); + } catch (_) { + if (mounted) { + setState(() { + _isCheckingEmail = false; + _isEmailAvailable = null; + _emailStatusMessage = null; + }); + } + } + }); + } + + Widget? _buildEmailSuffixIcon() { + if (_isCheckingEmail) { + return const Padding( + padding: EdgeInsets.all(12.0), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + ); + } + if (_isEmailAvailable == true) { + return const Icon( + Icons.check_circle_rounded, + color: Color(0xFF2E7D32), + size: 20, + ); + } + if (_isEmailAvailable == false) { + return const Icon( + Icons.cancel_rounded, + color: Color(0xFFD32F2F), + size: 20, + ); + } + return null; + } + + Future _handleRegister() async { + final name = _nameController.text.trim(); + final email = _emailController.text.trim().toLowerCase(); + final password = _passwordController.text.trim(); + + if (name.isEmpty || email.isEmpty || password.isEmpty) { + AppSnackBar.showWarning(context, 'Silakan lengkapi semua data formulir'); + return; + } + + if (_isCheckingEmail) { + AppSnackBar.showPrimary(context, 'Sedang memeriksa email, mohon tunggu...'); + return; + } + + if (_isEmailAvailable == false) { + AppSnackBar.showError( + context, + _emailStatusMessage ?? 'Email sudah terdaftar atau tidak valid', + ); + return; + } + + if (!_agreeTerms) { + AppSnackBar.showWarning(context, 'Anda harus menyetujui Syarat & Ketentuan Layanan'); + return; + } + + setState(() => _isLoading = true); + + try { + final authRepo = ref.read(authRepositoryProvider); + final user = await authRepo.register(name, email, password); + + if (!mounted) return; + AppSnackBar.showSuccess( + context, + 'Akun berhasil didaftarkan! Selamat datang di EVA, ${user.name}.', + ); + Navigator.pushReplacementNamed(context, '/home'); + } catch (e) { + if (!mounted) return; + AppSnackBar.showError( + context, + 'Registrasi gagal: ${e.toString().replaceAll('ApiException: ', '')}', + duration: const Duration(seconds: 4), + ); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF3F5FF), + Color(0xFFE9ECFF), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Brand Logo + Image.asset( + 'assets/images/eva_logo.png', + width: 50, + height: 50, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.nightlight_round, + size: 44, + color: AppColors.primary, + ); + }, + ), + const SizedBox(height: 8), + + Text( + 'Buat Akun Baru', + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + + Text( + 'Bergabung dengan EVA dan mulai merawat kesehatan mentalmu', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 24), + + // Full Name Field + CustomPillTextField( + controller: _nameController, + hintText: 'Nama Panggilan', + prefixIcon: Icons.person_outline, + ), + const SizedBox(height: 14), + + // Email Field + CustomPillTextField( + controller: _emailController, + hintText: 'Alamat Email', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + suffixIcon: _buildEmailSuffixIcon(), + onChanged: _onEmailChanged, + ), + if (_emailStatusMessage != null) ...[ + const SizedBox(height: 6), + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + _emailStatusMessage!, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: _isEmailAvailable == true + ? const Color(0xFF2E7D32) + : (_isEmailAvailable == false + ? const Color(0xFFD32F2F) + : AppColors.textSecondary), + ), + ), + ), + ), + ], + const SizedBox(height: 14), + + // Password Field + CustomPillTextField( + controller: _passwordController, + hintText: 'Kata Sandi', + prefixIcon: Icons.lock_outline, + isPassword: true, + ), + const SizedBox(height: 16), + + // Terms and Conditions Checkbox + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + height: 24, + width: 24, + child: Checkbox( + value: _agreeTerms, + activeColor: AppColors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + side: const BorderSide( + color: Color(0xFFC7D2FE), + width: 1.5, + ), + onChanged: (val) { + setState(() { + _agreeTerms = val ?? false; + }); + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text.rich( + TextSpan( + text: 'Saya menyetujui ', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + children: [ + TextSpan( + text: 'Syarat & Ketentuan', + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + const TextSpan(text: ' serta '), + TextSpan( + text: 'Kebijakan Privasi', + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 24), + + // Register Button + _isLoading + ? const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ) + : CustomPillButton( + text: 'Daftar Sekarang', + onPressed: _handleRegister, + ), + const SizedBox(height: 24), + + // Or Divider + const Row( + children: [ + Expanded(child: Divider(color: Color(0xFFD6DCF5))), + Padding( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: Text( + 'atau daftar dengan', + style: TextStyle( + fontSize: 12, + color: AppColors.textLight, + ), + ), + ), + Expanded(child: Divider(color: Color(0xFFD6DCF5))), + ], + ), + const SizedBox(height: 16), + + // Social Register Buttons Row + Row( + children: [ + Expanded( + child: SocialPillButton( + type: 'google', + onPressed: () { + showSocialAuthDevSheet(context, provider: 'Google'); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SocialPillButton( + type: 'apple', + onPressed: () { + showSocialAuthDevSheet(context, provider: 'Apple'); + }, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Login Link Footer + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Sudah punya akun? ', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + GestureDetector( + onTap: () { + Navigator.pushReplacementNamed(context, '/login'); + }, + child: Text( + 'Masuk', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), +); + } +} diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/settings_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/settings_screen.dart new file mode 100644 index 0000000..bbd89cb --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/settings_screen.dart @@ -0,0 +1,928 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/skeleton_shimmer.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + // Voice & AI Preferences + String _selectedVoiceMode = 'mode_2'; + double _speechRate = 1.0; + String _conversationMode = 'Hybrid (Deteksi Otomatis)'; + + // Audio Preview Player for Voice Character Cards + final AudioPlayer _previewPlayer = AudioPlayer(); + String? _playingPreviewModeId; + + // Wellness Notifications & Reminders + bool _dailyCheckInReminder = true; + bool _eveningJournalReminder = true; + bool _breathingExerciseReminder = false; + + // Privacy & Data Security + bool _incognitoSessionMode = false; + + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSavedSettings(); + } + + Future _loadSavedSettings() async { + try { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _selectedVoiceMode = prefs.getString('settings_voice_mode') ?? 'mode_2'; + _speechRate = prefs.getDouble('settings_speech_rate') ?? 1.0; + _conversationMode = prefs.getString('settings_conversation_mode') ?? 'Hybrid (Deteksi Otomatis)'; + _dailyCheckInReminder = prefs.getBool('settings_reminder_daily') ?? true; + _eveningJournalReminder = prefs.getBool('settings_reminder_evening') ?? true; + _breathingExerciseReminder = prefs.getBool('settings_reminder_breathing') ?? false; + _incognitoSessionMode = prefs.getBool('settings_incognito') ?? false; + _isLoading = false; + }); + } catch (_) { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _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); + AppSnackBar.showError( + context, + 'Memori percakapan AI berhasil dibersihkan.', + ); + }, + child: Text( + 'Ya, Hapus', + style: GoogleFonts.inter(color: Colors.white, fontWeight: FontWeight.w600), + ), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Column( + children: [ + // Header Bar with Clean Back Button + StaggeredEntrance( + index: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pengaturan & Preferensi', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Sesuaikan pengalaman terapeutik Eva', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + ), + ), + const Divider(height: 1, thickness: 0.5, color: AppColors.divider), + + // Scrollable Settings Content + Expanded( + child: _isLoading + ? const _SettingsProfileSkeletonLoader() + : SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // SECTION 1: PREFERENSI SUARA & AI + StaggeredEntrance( + index: 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader( + icon: Icons.record_voice_over_rounded, + title: 'SUARA & RESPON EVA AI', + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Karakter Suara Selector Cards + Text( + 'Karakter Suara Eva', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Pilih kepribadian vokal yang paling membuatmu merasa aman dan didengar.', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + _buildVoiceChoiceTile( + title: 'Eva - Jessica Ceria (Default)', + subtitle: 'Suara ramah, hangat, dan optimis dengan nada yang lebih ceria (Jessica + Happily)', + icon: Icons.sentiment_very_satisfied_rounded, + isSelected: _selectedVoiceMode == 'mode_2', + isDefault: true, + isPlayingPreview: _playingPreviewModeId == 'mode_2', + onPreviewTap: () => _toggleAudioPreview('mode_2', 'assets/audio/mode_2_jessica_ceria.mp3'), + onTap: () { + setState(() => _selectedVoiceMode = 'mode_2'); + _saveSetting('settings_voice_mode', 'mode_2'); + _saveSetting('settings_voice_character', 'Eva - Jessica Ceria (Default)'); + }, + ), + const SizedBox(height: 8), + _buildVoiceChoiceTile( + title: 'Eva - Jessica Playful', + subtitle: 'Suara lincah, ekspresif, manis, santai, dan akrab (Jessica + Playful)', + icon: Icons.mood_rounded, + isSelected: _selectedVoiceMode == 'mode_4', + isPlayingPreview: _playingPreviewModeId == 'mode_4', + onPreviewTap: () => _toggleAudioPreview('mode_4', 'assets/audio/mode_4_jessica_playful.mp3'), + onTap: () { + setState(() => _selectedVoiceMode = 'mode_4'); + _saveSetting('settings_voice_mode', 'mode_4'); + _saveSetting('settings_voice_character', 'Eva - Jessica Playful'); + }, + ), + const SizedBox(height: 8), + _buildVoiceChoiceTile( + title: 'Eva - Laura Muda & Enerjik', + subtitle: 'Suara perempuan muda, cerdas, artikulatif, dan dinamis (Laura + Happily)', + icon: Icons.bolt_rounded, + isSelected: _selectedVoiceMode == 'mode_7', + isPlayingPreview: _playingPreviewModeId == 'mode_7', + onPreviewTap: () => _toggleAudioPreview('mode_7', 'assets/audio/mode_7_laura_enerjik.mp3'), + onTap: () { + setState(() => _selectedVoiceMode = 'mode_7'); + _saveSetting('settings_voice_mode', 'mode_7'); + _saveSetting('settings_voice_character', 'Eva - Laura Muda & Enerjik'); + }, + ), + const SizedBox(height: 16), + const Divider(height: 1, thickness: 0.5, color: AppColors.divider), + const SizedBox(height: 14), + + // Kecepatan Bicara Slider + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Tempo Kecepatan Suara', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${_speechRate.toStringAsFixed(2)}x', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: AppColors.primary, + inactiveTrackColor: AppColors.primaryContainer, + thumbColor: AppColors.primary, + overlayColor: AppColors.primary.withValues(alpha: 0.15), + trackHeight: 4, + ), + child: Slider( + value: _speechRate, + min: 0.8, + max: 1.2, + divisions: 4, + label: '${_speechRate.toStringAsFixed(2)}x', + onChanged: (val) { + setState(() => _speechRate = val); + _saveSetting('settings_speech_rate', val); + }, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Lambat (0.8x)', style: GoogleFonts.inter(fontSize: 10, color: AppColors.textLight)), + Text('Normal (1.0x)', style: GoogleFonts.inter(fontSize: 10, color: AppColors.textLight)), + Text('Cepat (1.2x)', style: GoogleFonts.inter(fontSize: 10, color: AppColors.textLight)), + ], + ), + const SizedBox(height: 16), + const Divider(height: 1, thickness: 0.5, color: AppColors.divider), + const SizedBox(height: 14), + + // Mode Percakapan Suara + Text( + 'Metode Interaksi Suara', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildVoiceChoiceTile( + title: 'Hybrid (Deteksi Suara Otomatis)', + subtitle: 'Cukup berbicara langsung, Eva akan mendengarkan', + icon: Icons.graphic_eq_rounded, + isSelected: _conversationMode == 'Hybrid (Deteksi Otomatis)', + onTap: () { + setState(() => _conversationMode = 'Hybrid (Deteksi Otomatis)'); + _saveSetting('settings_conversation_mode', 'Hybrid (Deteksi Otomatis)'); + }, + ), + const SizedBox(height: 8), + _buildVoiceChoiceTile( + title: 'Push-To-Talk (Manual)', + subtitle: 'Tahan tombol untuk bicara, lepas untuk kirim suara', + icon: Icons.touch_app_rounded, + isSelected: _conversationMode == 'Push-To-Talk (Manual)', + onTap: () { + setState(() => _conversationMode = 'Push-To-Talk (Manual)'); + _saveSetting('settings_conversation_mode', 'Push-To-Talk (Manual)'); + }, + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // SECTION 2: PENGINGAT & KESEHATAN MENTAL + StaggeredEntrance( + index: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader( + icon: Icons.notifications_active_rounded, + title: 'PENGINGAT RUTINITAS WELLNESS', + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildSwitchTile( + icon: Icons.chat_bubble_outline_rounded, + title: 'Pengingat Sesi Curhat Pagi', + subtitle: 'Notifikasi ramah untuk menyapa emosimu saat memulai hari', + value: _dailyCheckInReminder, + onChanged: (val) { + setState(() => _dailyCheckInReminder = val); + _saveSetting('settings_reminder_daily', val); + }, + ), + const Divider(height: 20, thickness: 0.5, color: AppColors.divider), + _buildSwitchTile( + icon: Icons.menu_book_rounded, + title: 'Refleksi Jurnal Malam', + subtitle: 'Pengingat membaca sintesis emosi harian pukul 20:00 WIB', + value: _eveningJournalReminder, + onChanged: (val) { + setState(() => _eveningJournalReminder = val); + _saveSetting('settings_reminder_evening', val); + }, + ), + const Divider(height: 20, thickness: 0.5, color: AppColors.divider), + _buildSwitchTile( + icon: Icons.air_rounded, + title: 'Latihan Napas & Relaksasi', + subtitle: 'Ajakan latihan pernapasan 4-7-8 untuk menstabilkan detak jantung', + value: _breathingExerciseReminder, + onChanged: (val) { + setState(() => _breathingExerciseReminder = val); + _saveSetting('settings_reminder_breathing', val); + }, + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // SECTION 3: PRIVASI & KEAMANAN DATA + StaggeredEntrance( + index: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader( + icon: Icons.shield_outlined, + title: 'PRIVASI & KEAMANAN DATA', + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildSwitchTile( + icon: Icons.visibility_off_outlined, + title: 'Mode Sesi Privat (Incognito)', + subtitle: 'Sesi dialog tidak dicatat ke memori jangka panjang AI', + value: _incognitoSessionMode, + onChanged: (val) { + setState(() => _incognitoSessionMode = val); + _saveSetting('settings_incognito', val); + }, + ), + const Divider(height: 20, thickness: 0.5, color: AppColors.divider), + _buildActionTile( + icon: Icons.delete_outline_rounded, + iconColor: const Color(0xFFD32F2F), + title: 'Hapus Memori AI Eva', + subtitle: 'Bersihkan seluruh riwayat konteks emosional yang tersimpan', + onTap: _showResetMemoryDialog, + ), + const Divider(height: 20, thickness: 0.5, color: AppColors.divider), + _buildActionTile( + icon: Icons.contact_phone_outlined, + iconColor: AppColors.primary, + title: 'Kontak Darurat & Protokol Krisis', + subtitle: 'Kelola kontak darurat terpercaya dan hotline resmi 24 jam', + onTap: () => Navigator.pushNamed(context, '/emergency_contacts'), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // SECTION 4: TENTANG APLIKASI + StaggeredEntrance( + index: 4, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader( + icon: Icons.info_outline_rounded, + title: 'TENTANG APLIKASI', + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildActionTile( + icon: Icons.description_outlined, + iconColor: AppColors.primary, + title: 'Syarat, Ketentuan & Disclaimer Medis', + subtitle: 'Pelajari batasan klinis dan privasi data pengguna', + onTap: () => Navigator.pushNamed(context, '/terms_disclaimer'), + ), + const Divider(height: 20, thickness: 0.5, color: AppColors.divider), + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.verified_outlined, size: 18, color: AppColors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Versi Aplikasi', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + 'EVA AI v1.0.4 • Build 2026.09 (Production)', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFD1FAE5), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Terbaru', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF047857), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSectionHeader({required IconData icon, required String title}) { + return Row( + children: [ + Icon(icon, size: 14, color: AppColors.primary), + const SizedBox(width: 6), + Text( + title, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 0.8, + ), + ), + ], + ); + } + + Widget _buildVoiceChoiceTile({ + required String title, + required String subtitle, + required IconData icon, + required bool isSelected, + required VoidCallback onTap, + bool isDefault = false, + bool isPlayingPreview = false, + VoidCallback? onPreviewTap, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFFF0FDF4) : Colors.white.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? const Color(0xFF10B981) : const Color(0xFFE2E8F0), + width: isSelected ? 1.5 : 1.0, + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFFD1FAE5) : const Color(0xFFF1F5F9), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 16, + color: isSelected ? const Color(0xFF047857) : AppColors.textLight, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + if (isDefault) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), + decoration: BoxDecoration( + color: const Color(0xFFD1FAE5), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'DEFAULT', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w800, + color: const Color(0xFF047857), + letterSpacing: 0.4, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + if (onPreviewTap != null) ...[ + IconButton( + onPressed: onPreviewTap, + icon: Icon( + isPlayingPreview ? Icons.stop_circle_rounded : Icons.play_circle_fill_rounded, + color: isPlayingPreview ? const Color(0xFFEF4444) : const Color(0xFF10B981), + size: 26, + ), + tooltip: isPlayingPreview ? 'Hentikan sampel' : 'Dengarkan sampel', + constraints: const BoxConstraints(), + padding: const EdgeInsets.symmetric(horizontal: 6), + ), + ], + Icon( + isSelected ? Icons.check_circle_rounded : Icons.radio_button_off_rounded, + size: 20, + color: isSelected ? const Color(0xFF10B981) : AppColors.textLight, + ), + ], + ), + ), + ), + ); + } + + Widget _buildSwitchTile({ + required IconData icon, + required String title, + required String subtitle, + required bool value, + required ValueChanged onChanged, + }) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: AppColors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Switch.adaptive( + value: value, + activeThumbColor: const Color(0xFF10B981), + activeTrackColor: const Color(0xFFD1FAE5), + onChanged: onChanged, + ), + ], + ); + } + + Widget _buildActionTile({ + required IconData icon, + required Color iconColor, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: iconColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right, size: 18, color: AppColors.textLight), + ], + ), + ), + ); + } +} + +/// Shimmer skeleton loader for Settings screen. +class _SettingsProfileSkeletonLoader extends StatelessWidget { + const _SettingsProfileSkeletonLoader(); + + @override + Widget build(BuildContext context) { + return SkeletonShimmerHost( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section 1: Voice & AI Preferences + Row( + children: const [ + SkeletonCircle(size: 20), + SizedBox(width: 8), + SkeletonLine(width: 140, height: 13), + ], + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 120, height: 14), + SizedBox(height: 12), + SkeletonBox(width: double.infinity, height: 44, borderRadius: 10), + SizedBox(height: 16), + SkeletonLine(width: 100, height: 14), + SizedBox(height: 12), + SkeletonBox(width: double.infinity, height: 28, borderRadius: 10), + ], + ), + ), + const SizedBox(height: 24), + + // Section 2: Notifications & Reminders + Row( + children: const [ + SkeletonCircle(size: 20), + SizedBox(width: 8), + SkeletonLine(width: 170, height: 13), + ], + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + children: List.generate( + 3, + (index) => Padding( + padding: EdgeInsets.only(bottom: index == 2 ? 0 : 16.0), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 140, height: 14), + SizedBox(height: 6), + SkeletonLine(width: 190, height: 11), + ], + ), + ), + const SkeletonBox(width: 44, height: 24, borderRadius: 12), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/splash_onboarding_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/splash_onboarding_screen.dart new file mode 100644 index 0000000..5748298 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/splash_onboarding_screen.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import '../providers/auth_provider.dart'; + +class SplashOnboardingScreen extends ConsumerStatefulWidget { + const SplashOnboardingScreen({super.key}); + + @override + ConsumerState createState() => _SplashOnboardingScreenState(); +} + +class _SplashOnboardingScreenState extends ConsumerState { + 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 AuthRepository + try { + if (token != null && token.isNotEmpty) { + final user = await ref.read(authRepositoryProvider).getCurrentUser(); + if (user != null) { + await AppConfig.setUserInfo( + name: user.name, + email: user.email, + ); + isAuthenticated = true; + } + } + } catch (_) {} + + // 2. If access token is invalid/expired, attempt refresh token + if (!isAuthenticated && refreshToken != null && refreshToken.isNotEmpty) { + isAuthenticated = await AppConfig.refreshAccessToken(); + } + + if (!mounted) return; + + if (isAuthenticated) { + Navigator.pushReplacementNamed(context, '/home'); + } else { + // Clear invalid local credentials + if (token != null || refreshToken != null) { + await AppConfig.clearToken(); + } + if (mounted) { + setState(() => _isCheckingAuth = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF3F5FF), + Color(0xFFE9ECFF), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + child: _isCheckingAuth + ? _buildMinimalistSplash() + : _buildOnboardingContent(), + ), + ), + ), + ); + } + + /// Minimalist native-like splash screen displaying the centered app icon and branding. + Widget _buildMinimalistSplash() { + return Center( + key: const ValueKey('minimalist_splash'), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/images/eva_logo.png', + width: 72, + height: 72, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.nightlight_round, + size: 64, + color: AppColors.primary, + ); + }, + ), + const SizedBox(height: 16), + Text( + 'EVA', + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 4), + Text( + 'PENDAMPING KESEHATAN MENTAL', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.primary.withValues(alpha: 0.7), + letterSpacing: 1.8, + ), + ), + ], + ), + ); + } + + /// Onboarding content card shown after session verification when user is unauthenticated. + Widget _buildOnboardingContent() { + return LayoutBuilder( + key: const ValueKey('onboarding_content'), + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + children: [ + const SizedBox(height: 16), + + // Top Logo & Brand + Image.asset( + 'assets/images/eva_logo.png', + width: 44, + height: 44, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.nightlight_round, + size: 40, + color: AppColors.primary, + ); + }, + ), + const SizedBox(height: 4), + Text( + 'EVA', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 1.2, + ), + ), + Text( + 'PENDAMPING KESEHATAN MENTAL', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.primary.withValues(alpha: 0.7), + letterSpacing: 1.5, + ), + ), + + const Spacer(), + + // Center 3D Glowing Orb Visual + Center( + child: Container( + width: 220, + height: 220, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const SweepGradient( + colors: [ + Color(0xFFFFB6C1), + Color(0xFFE2DAFF), + Color(0xFFA7E6FF), + Color(0xFF8B93FF), + Color(0xFFFFB6C1), + ], + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.3), + blurRadius: 40, + spreadRadius: 5, + ), + ], + ), + child: Container( + margin: const EdgeInsets.all(3), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + Colors.white, + Color(0xFFEADBFF), + Color(0xFFA7E6FF), + ], + center: Alignment(-0.3, -0.3), + radius: 0.8, + ), + ), + ), + ), + ), + + const Spacer(), + + // Bottom Glass Card Onboarding Content + GlassCard( + padding: const EdgeInsets.all(28.0), + child: Column( + children: [ + Text( + 'Selamat Datang di EVA', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 12), + Text( + 'Ruang aman tempat pikiranmu didengar, dipahami, dan dirawat tanpa penilaian.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 24), + + // Primary Button: Start Your Journey + CustomPillButton( + text: 'Mulai Perjalananmu', + onPressed: () { + Navigator.pushNamed(context, '/register'); + }, + ), + const SizedBox(height: 14), + + // Login Link + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Sudah punya akun? ', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/login'); + }, + child: Text( + 'Masuk', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/terms_disclaimer_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/terms_disclaimer_screen.dart new file mode 100644 index 0000000..be70b86 --- /dev/null +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/terms_disclaimer_screen.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; + +class TermsDisclaimerScreen extends StatelessWidget { + const TermsDisclaimerScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF3F5FF), + Color(0xFFE9ECFF), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Column( + children: [ + // Custom Header Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Text( + 'Syarat & Disclaimer Etika', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + + // Main Content ScrollView + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 8.0), + child: Column( + children: [ + // Header Card + GlassCard( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.gavel_rounded, + color: AppColors.primary, + size: 26, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Eva AI Ethics & Terms', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Panduan Etika, Batasan Medis & Kebijakan Layanan', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Section 1: Prinsip Etika Layanan + _buildSectionCard( + icon: Icons.favorite_border_rounded, + iconColor: const Color(0xFF6C5CE7), + title: '1. Prinsip Etika Konseling AI', + content: [ + 'Non-Judgmental Space: Eva AI dirancang untuk menyediakan ruang refleksi emosional yang aman tanpa penilaian moral atau preskriptif.', + 'Empati CBT Ringan: Bahasa interaksi mengadopsi pendekatan Cognitive Behavioral Therapy (CBT) ringan untuk membantu pengguna memvalidasi emosi secara sehat.', + 'Aksesibilitas 24/7: Layanan ini beroperasi 24 jam sehari untuk mendampingi kesehatan mental harian pengguna.', + ], + ), + const SizedBox(height: 16), + + // Section 2: Batasan Wewenang Medis (Medical Disclaimer) + _buildSectionCard( + icon: Icons.health_and_safety_outlined, + iconColor: const Color(0xFFD32F2F), + title: '2. Batasan Wewenang Medis (Medical Disclaimer)', + content: [ + 'Bukan Tenaga Medis Berlisensi: Eva AI adalah sistem pendukung berbasis kecerdasan buatan, BUKAN psikolog klinis, psikiater, atau fasilitas kesehatan manusia.', + 'Tidak Berwewenang Memberikan Diagnosis: Eva AI TIDAK memberikan diagnosis klinis resmi (seperti Depresi Klinis, Bipolar, Skizofrenia, atau Gangguan Kecemasan Akut).', + 'Tanpa Resep Obat: Eva AI TIDAK BERWEWENANG mengeluarakan atau menyarankan resep obat-obatan psikiatri/medikal.', + 'Pengganti Terapi Nyata: Layanan ini tidak dimaksudkan untuk menggantikan sesi terapi langsung dengan profesional kesehatan mental berlisensi.', + ], + highlightColor: const Color(0xFFFFF5F5), + ), + const SizedBox(height: 16), + + // Section 3: Protokol Keselamatan & Penanganan Krisis + _buildSectionCard( + icon: Icons.shield_outlined, + iconColor: const Color(0xFFE67E22), + title: '3. Protokol Keselamatan Emosional & Krisis', + content: [ + 'Multi-layered Safety Gate: Sistem memindai pesan secara real-time untuk mendeteksi indikasi risiko krisis emosional atau kecenderungan mencederai diri (Self-Harm / Suicidal Ideation).', + 'Eskalasi Krisis Otomatis: Apabila terdeteksi indikasi krisis tinggi (High Risk), sesi konseling standar akan dihentikan dan sistem secara otomatis memicu halaman penanganan krisis darurat.', + 'Kontak Darurat & Hotline: Pengguna akan diarahkan ke Hotline Kesehatan Mental Resmi (Layanan Sejiwa 119 ext 8) serta opsi untuk menghubungkan Kontak Darurat Utama.', + ], + ), + const SizedBox(height: 16), + + // Section 4: Kebijakan Privasi & Etika Penggunaan + _buildSectionCard( + icon: Icons.lock_outline_rounded, + iconColor: const Color(0xFF00B894), + title: '4. Kerahasiaan Data & Etika Penggunaan', + content: [ + 'Kerahasiaan Percakapan: Data sesi percakapan dan jurnal Anda dilindungi dan tidak dijual atau dibagikan kepada pihak ketiga non-medis tanpa persetujuan Anda.', + 'Tata Tertib Penggunaan: Pengguna dilarang memanfaatkan AI untuk mempromosikan tindakan ilegal, membahayakan orang lain, atau melakukan peretasan prompt (jailbreaking).', + 'Sintesis Jurnal Otomatis: Ringkasan emosional dibuat otomatis untuk membantu pengguna memantau perkembangan kesejahteraan mental secara mandiri.', + ], + ), + const SizedBox(height: 24), + + // Footer Statement + Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Text( + 'Dengan menggunakan aplikasi Eva, Anda menyatakan telah membaca, memahami, dan menyetujui seluruh ketentuan etika dan batasan layanan di atas.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSectionCard({ + required IconData icon, + required Color iconColor, + required String title, + required List content, + Color? highlightColor, + }) { + return GlassCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: iconColor, size: 22), + const SizedBox(width: 10), + Expanded( + child: Text( + title, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1, color: Color(0xFFE2E8F0)), + const SizedBox(height: 12), + ...content.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '• ', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.bold, + color: iconColor, + ), + ), + Expanded( + child: Text( + item, + style: GoogleFonts.inter( + fontSize: 12.5, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/data/datasources/care_datasource.dart b/apps/eva_mobile/lib/features/care/data/datasources/care_datasource.dart new file mode 100644 index 0000000..cd5320f --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/datasources/care_datasource.dart @@ -0,0 +1,19 @@ +import '../../domain/entities/consultation_session_entity.dart'; +import '../models/psychologist_model.dart'; +import '../models/clinic_model.dart'; +import '../models/community_status_model.dart'; +import '../models/consultation_session_model.dart'; + +abstract class CareDataSource { + Future> getPsychologists({String? category, String? query}); + Future> getClinics({String? query}); + Future getCommunityStatus(); + Future joinCommunityWithVipTrial(); + Future> getConsultationSessions(); + Future bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }); +} diff --git a/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart b/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart new file mode 100644 index 0000000..7e7432b --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart @@ -0,0 +1,260 @@ +import 'dart:async'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../models/psychologist_model.dart'; +import '../models/clinic_model.dart'; +import '../models/community_status_model.dart'; +import '../models/consultation_session_model.dart'; +import 'care_datasource.dart'; + +class MockCareDataSource implements CareDataSource { + // In-memory state for reactive demo & offline support + CommunityStatusModel _communityStatus = const CommunityStatusModel( + isMember: false, + isVipActive: false, + vipRemainingDays: 0, + serverName: 'Eva Safe Space ID', + inviteUrl: 'https://discord.gg/evasafespace', + memberCount: 1420, + ); + + final List _psychologists = [ + const PsychologistModel( + id: 'psy_1', + name: 'Amanda Zahra, M.Psi., Psikolog', + title: 'Psikolog Klinis Dewasa', + registrationNumber: 'SIPP: 1988-12-04-2021', + avatarUrl: 'https://images.unsplash.com/photo-1594824813583-059df30f1484?w=400&auto=format&fit=crop&q=80', + rating: 4.95, + reviewCount: 68, + experienceYears: 6, + specialties: ['Kecemasan', 'Quarter-Life Crisis', 'Burnout Karir', 'Self-Esteem'], + feePerSession: 140000, + durationMinutes: 50, + availableOnline: true, + clinicAffiliation: 'Klinik Sahabat Jiwa Jakarta', + bio: 'Fokus pada pendekatan Cognitive Behavioral Therapy (CBT) dan Acceptance and Commitment Therapy (ACT) untuk membantu individu mengatasi stres kerja, overthinking, dan krisis identitas.', + ), + const PsychologistModel( + id: 'psy_2', + name: 'dr. Hendra Kusuma, Sp.KJ', + title: 'Dokter Spesialis Kedokteran Jiwa (Psikiater)', + registrationNumber: 'STR: 31.1.1.100.2.20.123456', + avatarUrl: 'https://images.unsplash.com/photo-1622253692010-333f2da6031d?w=400&auto=format&fit=crop&q=80', + rating: 4.98, + reviewCount: 112, + experienceYears: 10, + specialties: ['Gangguan Mood / Bipolar', 'Depresi Berat', 'Insomnia Kronis', 'Farmakoterapi'], + feePerSession: 250000, + durationMinutes: 45, + availableOnline: true, + clinicAffiliation: 'RS Jiwa & Emosi Harmoni', + bio: 'Menangani gangguan afektif, kecemasan akut, dan depresi berat dengan kombinasi intervensi psikofarmaka rasional serta psikoedukasi holistik.', + ), + const PsychologistModel( + id: 'psy_3', + name: 'Rian Pratama, M.Psi., Psikolog', + title: 'Psikolog Klinis & Konselor Hubungan', + registrationNumber: 'SIPP: 1991-05-18-2022', + avatarUrl: 'https://images.unsplash.com/photo-1537368910025-700350fe46c7?w=400&auto=format&fit=crop&q=80', + rating: 4.88, + reviewCount: 45, + experienceYears: 5, + specialties: ['Dinamika Keluarga', 'Trauma Masa Lalu', 'Komunikasi Pasangan', 'Grief'], + feePerSession: 130000, + durationMinutes: 50, + availableOnline: true, + clinicAffiliation: 'Pusat Konseling Bina Jiwa', + bio: 'Menggunakan pendekatan humanistik dan mindfulness untuk memfasilitasi rekonsiliasi emosional serta penyembuhan luka batin relasional.', + ), + const PsychologistModel( + id: 'psy_4', + name: 'dr. Citra Lestari, Sp.KJ, Subsp.AR(K)', + title: 'Psikiater Konsultan Adiksi & Remaja', + registrationNumber: 'STR: 32.2.1.200.3.19.876543', + avatarUrl: 'https://images.unsplash.com/photo-1559839734-2b71ea197ec2?w=400&auto=format&fit=crop&q=80', + rating: 4.92, + reviewCount: 54, + experienceYears: 8, + specialties: ['Adiksi Digital/Gadget', 'Kecemasan Remaja', 'ADHD Dewasa', 'Panic Disorder'], + feePerSession: 275000, + durationMinutes: 45, + availableOnline: true, + clinicAffiliation: 'Klinik Sahabat Jiwa Jakarta', + bio: 'Spesialis kesehatan mental transisi usia muda dan dewasa, pemulihan regulasi dopamin, dan manajemen serangan panik.', + ), + const PsychologistModel( + id: 'psy_5', + name: 'Nathania Stephanie, S.Psi., M.Psi.', + title: 'Psikolog Anak & Mindfulness Coach', + registrationNumber: 'SIPP: 1994-08-22-2023', + avatarUrl: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&auto=format&fit=crop&q=80', + rating: 4.90, + reviewCount: 39, + experienceYears: 4, + specialties: ['Mindfulness Based Therapy', 'Manajemen Emosi', 'Inner Child Healing', 'Burnout'], + feePerSession: 125000, + durationMinutes: 50, + availableOnline: true, + clinicAffiliation: 'Ruang Tumbuh Jiwa', + bio: 'Membimbing individu menemukan kedamaian batin melalui latihan pernapasan, relaksasi somatik, dan penerimaan diri secara utuh.', + ), + ]; + + final List _clinics = [ + const ClinicModel( + id: 'clinic_1', + name: 'Klinik Utama Sahabat Jiwa', + address: 'Jl. Tebet Barat Raya No. 42, Tebet, Jakarta Selatan', + city: 'Jakarta Selatan', + distanceKm: 2.3, + photoUrl: 'https://images.unsplash.com/photo-1519494026892-80bbd2d6fd0d?w=400&auto=format&fit=crop&q=80', + phone: '021-83701234', + facilities: ['Ruang Konseling Privat', 'Farmasi Jiwa', 'Ruang Meditasi', 'Parkir Luas'], + rating: 4.9, + ), + const ClinicModel( + id: 'clinic_2', + name: 'RS Jiwa & Emosi Harmoni', + address: 'Jl. Dr. Saharjo No. 108, Setiabudi, Jakarta Selatan', + city: 'Jakarta Selatan', + distanceKm: 4.1, + photoUrl: 'https://images.unsplash.com/photo-1586773860418-d37222d8fce3?w=400&auto=format&fit=crop&q=80', + phone: '021-82909988', + facilities: ['UGD Psikiatri 24 Jam', 'Laboratorium Tes', 'Konseling Pasangan', 'Ambulans'], + rating: 4.8, + ), + const ClinicModel( + id: 'clinic_3', + name: 'Pusat Konseling Bina Jiwa Bandung', + address: 'Jl. Cihampelas No. 156, Coblong, Bandung', + city: 'Bandung', + distanceKm: 128.0, + photoUrl: 'https://images.unsplash.com/photo-1629909613654-28e377c37b09?w=400&auto=format&fit=crop&q=80', + phone: '022-2031122', + facilities: ['Konseling Komunitas', 'Workshop Art Therapy', 'Taman Terbuka'], + rating: 4.7, + ), + ]; + + late final List _sessions = [ + ConsultationSessionModel( + id: 'sess_1', + psychologist: _psychologists[0], // Amanda Zahra + sessionDateTime: DateTime.now().add(const Duration(days: 1, hours: 3)), + status: ConsultationStatus.upcoming, + type: ConsultationType.onlineVideo, + meetingUrl: 'https://meet.google.com/eva-care-session', + notes: 'Fokus sesi: Evaluasi kecemasan menjelang presentasi kerja & latihan grounding 5-4-3-2-1.', + ), + ConsultationSessionModel( + id: 'sess_2', + psychologist: _psychologists[1], // dr. Hendra Kusuma + sessionDateTime: DateTime.now().subtract(const Duration(days: 12)), + status: ConsultationStatus.completed, + type: ConsultationType.onlineVideo, + meetingUrl: 'https://meet.google.com/eva-care-prev', + notes: 'Diagnosis awal: Mild somatic anxiety dengan gangguan pola tidur. Disarankan melanjutkan relaksasi pernapasan 4-7-8 dan pencatatan diary rutin di Eva.', + diagnosisSummary: 'Rekomendasi kontrol 1 bulan atau saat ada lonjakan stres signifikan.', + ), + ]; + + @override + Future> getPsychologists({String? category, String? query}) async { + await Future.delayed(const Duration(milliseconds: 300)); + var result = List.from(_psychologists); + + if (category != null && category.isNotEmpty && category != 'all') { + if (category == 'psychiatrist') { + result = result.where((p) => p.isPsychiatrist).toList(); + } else if (category == 'psychologist') { + result = result.where((p) => !p.isPsychiatrist).toList(); + } + } + + if (query != null && query.trim().isNotEmpty) { + final q = query.toLowerCase().trim(); + result = result.where((p) { + final matchesName = p.name.toLowerCase().contains(q); + final matchesSpecialty = p.specialties.any((s) => s.toLowerCase().contains(q)); + final matchesClinic = p.clinicAffiliation?.toLowerCase().contains(q) ?? false; + return matchesName || matchesSpecialty || matchesClinic; + }).toList(); + } + + return result; + } + + @override + Future> getClinics({String? query}) async { + await Future.delayed(const Duration(milliseconds: 300)); + var result = List.from(_clinics); + if (query != null && query.trim().isNotEmpty) { + final q = query.toLowerCase().trim(); + result = result.where((c) { + return c.name.toLowerCase().contains(q) || + c.city.toLowerCase().contains(q) || + c.address.toLowerCase().contains(q); + }).toList(); + } + return result; + } + + @override + Future getCommunityStatus() async { + await Future.delayed(const Duration(milliseconds: 150)); + return _communityStatus; + } + + @override + Future joinCommunityWithVipTrial() async { + await Future.delayed(const Duration(milliseconds: 400)); + _communityStatus = _communityStatus.copyWith( + isMember: true, + isVipActive: true, + vipRemainingDays: 30, + joinedAt: DateTime.now(), + memberCount: _communityStatus.memberCount + 1, + ) as CommunityStatusModel; + return _communityStatus; + } + + @override + Future> getConsultationSessions() async { + await Future.delayed(const Duration(milliseconds: 200)); + return List.from(_sessions); + } + + @override + Future bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) async { + await Future.delayed(const Duration(milliseconds: 400)); + final psychologist = _psychologists.firstWhere( + (p) => p.id == psychologistId, + orElse: () => _psychologists.first, + ); + + final newSession = ConsultationSessionModel( + id: 'sess_${DateTime.now().millisecondsSinceEpoch}', + psychologist: psychologist, + sessionDateTime: preferredDateTime, + status: ConsultationStatus.upcoming, + type: sessionType, + meetingUrl: sessionType == ConsultationType.onlineVideo + ? 'https://meet.google.com/eva-care-${DateTime.now().millisecondsSinceEpoch.toString().substring(8)}' + : null, + locationName: sessionType == ConsultationType.offlineClinic + ? (psychologist.clinicAffiliation ?? 'Klinik Rekanan Eva') + : null, + notes: complaints != null && complaints.isNotEmpty + ? 'Keluhan awal: $complaints' + : 'Sesi konsultasi pendampingan emosi.', + ); + + _sessions.insert(0, newSession); + return newSession; + } +} diff --git a/apps/eva_mobile/lib/features/care/data/datasources/remote_care_datasource.dart b/apps/eva_mobile/lib/features/care/data/datasources/remote_care_datasource.dart new file mode 100644 index 0000000..754e0c2 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/datasources/remote_care_datasource.dart @@ -0,0 +1,90 @@ +import '../../../../core/network/api_client.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../models/psychologist_model.dart'; +import '../models/clinic_model.dart'; +import '../models/community_status_model.dart'; +import '../models/consultation_session_model.dart'; +import 'care_datasource.dart'; + +class RemoteCareDataSource implements CareDataSource { + final ApiClient _apiClient; + + RemoteCareDataSource({ApiClient? apiClient}) : _apiClient = apiClient ?? ApiClient(); + + @override + Future> getPsychologists({String? category, String? query}) async { + final queryParams = {}; + if (category != null && category.isNotEmpty) queryParams['category'] = category; + if (query != null && query.isNotEmpty) queryParams['q'] = query; + + final response = await _apiClient.get( + '/care/psychologists', + queryParams: queryParams.isNotEmpty ? queryParams : null, + ); + + final list = response as List? ?? []; + return list.map((e) => PsychologistModel.fromJson(e as Map)).toList(); + } + + @override + Future> getClinics({String? query}) async { + final queryParams = {}; + if (query != null && query.isNotEmpty) queryParams['q'] = query; + + final response = await _apiClient.get( + '/care/clinics', + queryParams: queryParams.isNotEmpty ? queryParams : null, + ); + + final list = response as List? ?? []; + return list.map((e) => ClinicModel.fromJson(e as Map)).toList(); + } + + @override + Future getCommunityStatus() async { + final response = await _apiClient.get('/community/status'); + if (response is Map) { + return CommunityStatusModel.fromJson(response); + } + return const CommunityStatusModel(isMember: false, isVipActive: false); + } + + @override + Future joinCommunityWithVipTrial() async { + final response = await _apiClient.post('/community/join'); + if (response is Map) { + return CommunityStatusModel.fromJson(response); + } + return const CommunityStatusModel(isMember: true, isVipActive: true, vipRemainingDays: 30); + } + + @override + Future> getConsultationSessions() async { + final response = await _apiClient.get('/care/sessions'); + final list = response as List? ?? []; + return list.map((e) => ConsultationSessionModel.fromJson(e as Map)).toList(); + } + + @override + Future bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) async { + final response = await _apiClient.post( + '/care/book', + body: { + 'psychologist_id': psychologistId, + 'preferred_datetime': preferredDateTime.toIso8601String(), + 'session_type': sessionType.name, + 'complaints': complaints, + }, + ); + + if (response is Map) { + return ConsultationSessionModel.fromJson(response); + } + throw Exception('Failed to book consultation'); + } +} diff --git a/apps/eva_mobile/lib/features/care/data/models/clinic_model.dart b/apps/eva_mobile/lib/features/care/data/models/clinic_model.dart new file mode 100644 index 0000000..628d427 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/models/clinic_model.dart @@ -0,0 +1,46 @@ +import '../../domain/entities/clinic_entity.dart'; + +class ClinicModel extends ClinicEntity { + const ClinicModel({ + required super.id, + required super.name, + required super.address, + required super.city, + required super.distanceKm, + required super.photoUrl, + required super.phone, + required super.facilities, + super.rating = 4.8, + }); + + factory ClinicModel.fromJson(Map json) { + return ClinicModel( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + address: json['address'] as String? ?? '', + city: json['city'] as String? ?? '', + distanceKm: (json['distance_km'] as num?)?.toDouble() ?? 1.0, + photoUrl: json['photo_url'] as String? ?? '', + phone: json['phone'] as String? ?? '', + facilities: (json['facilities'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + rating: (json['rating'] as num?)?.toDouble() ?? 4.8, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'address': address, + 'city': city, + 'distance_km': distanceKm, + 'photo_url': photoUrl, + 'phone': phone, + 'facilities': facilities, + 'rating': rating, + }; + } +} diff --git a/apps/eva_mobile/lib/features/care/data/models/community_status_model.dart b/apps/eva_mobile/lib/features/care/data/models/community_status_model.dart new file mode 100644 index 0000000..c3e7c6a --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/models/community_status_model.dart @@ -0,0 +1,39 @@ +import '../../domain/entities/community_status_entity.dart'; + +class CommunityStatusModel extends CommunityStatusEntity { + const CommunityStatusModel({ + required super.isMember, + required super.isVipActive, + super.vipRemainingDays = 0, + super.joinedAt, + super.serverName = 'Eva Safe Space ID', + super.inviteUrl = 'https://discord.gg/evasafespace', + super.memberCount = 1420, + }); + + factory CommunityStatusModel.fromJson(Map json) { + return CommunityStatusModel( + isMember: json['is_member'] as bool? ?? false, + isVipActive: json['is_vip_active'] as bool? ?? false, + vipRemainingDays: (json['vip_remaining_days'] as num?)?.toInt() ?? 0, + joinedAt: json['joined_at'] != null + ? DateTime.tryParse(json['joined_at'] as String) + : null, + serverName: json['server_name'] as String? ?? 'Eva Safe Space ID', + inviteUrl: json['invite_url'] as String? ?? 'https://discord.gg/evasafespace', + memberCount: (json['member_count'] as num?)?.toInt() ?? 1420, + ); + } + + Map toJson() { + return { + 'is_member': isMember, + 'is_vip_active': isVipActive, + 'vip_remaining_days': vipRemainingDays, + 'joined_at': joinedAt?.toIso8601String(), + 'server_name': serverName, + 'invite_url': inviteUrl, + 'member_count': memberCount, + }; + } +} diff --git a/apps/eva_mobile/lib/features/care/data/models/consultation_session_model.dart b/apps/eva_mobile/lib/features/care/data/models/consultation_session_model.dart new file mode 100644 index 0000000..9c8c335 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/models/consultation_session_model.dart @@ -0,0 +1,68 @@ +import '../../domain/entities/consultation_session_entity.dart'; +import 'psychologist_model.dart'; + +class ConsultationSessionModel extends ConsultationSessionEntity { + const ConsultationSessionModel({ + required super.id, + required super.psychologist, + required super.sessionDateTime, + required super.status, + super.type = ConsultationType.onlineVideo, + super.meetingUrl, + super.locationName, + super.notes, + super.diagnosisSummary, + }); + + factory ConsultationSessionModel.fromJson(Map json) { + return ConsultationSessionModel( + id: json['id'] as String? ?? '', + psychologist: PsychologistModel.fromJson( + json['psychologist'] as Map? ?? {}, + ), + sessionDateTime: DateTime.tryParse(json['session_datetime'] as String? ?? '') ?? + DateTime.now(), + status: _parseStatus(json['status'] as String?), + type: _parseType(json['type'] as String?), + meetingUrl: json['meeting_url'] as String?, + locationName: json['location_name'] as String?, + notes: json['notes'] as String?, + diagnosisSummary: json['diagnosis_summary'] as String?, + ); + } + + static ConsultationStatus _parseStatus(String? status) { + switch (status?.toLowerCase()) { + case 'completed': + return ConsultationStatus.completed; + case 'cancelled': + return ConsultationStatus.cancelled; + default: + return ConsultationStatus.upcoming; + } + } + + static ConsultationType _parseType(String? type) { + switch (type?.toLowerCase()) { + case 'offline_clinic': + case 'offline': + return ConsultationType.offlineClinic; + default: + return ConsultationType.onlineVideo; + } + } + + Map toJson() { + return { + 'id': id, + 'psychologist': (psychologist as PsychologistModel).toJson(), + 'session_datetime': sessionDateTime.toIso8601String(), + 'status': status.name, + 'type': type.name, + 'meeting_url': meetingUrl, + 'location_name': locationName, + 'notes': notes, + 'diagnosis_summary': diagnosisSummary, + }; + } +} diff --git a/apps/eva_mobile/lib/features/care/data/models/psychologist_model.dart b/apps/eva_mobile/lib/features/care/data/models/psychologist_model.dart new file mode 100644 index 0000000..7b259d3 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/models/psychologist_model.dart @@ -0,0 +1,61 @@ +import '../../domain/entities/psychologist_entity.dart'; + +class PsychologistModel extends PsychologistEntity { + const PsychologistModel({ + required super.id, + required super.name, + required super.title, + required super.registrationNumber, + required super.avatarUrl, + required super.rating, + required super.reviewCount, + required super.experienceYears, + required super.specialties, + required super.feePerSession, + super.durationMinutes = 50, + super.availableOnline = true, + super.clinicAffiliation, + required super.bio, + }); + + factory PsychologistModel.fromJson(Map json) { + return PsychologistModel( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + title: json['title'] as String? ?? '', + registrationNumber: json['registration_number'] as String? ?? '', + avatarUrl: json['avatar_url'] as String? ?? '', + rating: (json['rating'] as num?)?.toDouble() ?? 5.0, + reviewCount: (json['review_count'] as num?)?.toInt() ?? 0, + experienceYears: (json['experience_years'] as num?)?.toInt() ?? 1, + specialties: (json['specialties'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + feePerSession: (json['fee_per_session'] as num?)?.toInt() ?? 100000, + durationMinutes: (json['duration_minutes'] as num?)?.toInt() ?? 50, + availableOnline: json['available_online'] as bool? ?? true, + clinicAffiliation: json['clinic_affiliation'] as String?, + bio: json['bio'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'title': title, + 'registration_number': registrationNumber, + 'avatar_url': avatarUrl, + 'rating': rating, + 'review_count': reviewCount, + 'experience_years': experienceYears, + 'specialties': specialties, + 'fee_per_session': feePerSession, + 'duration_minutes': durationMinutes, + 'available_online': availableOnline, + 'clinic_affiliation': clinicAffiliation, + 'bio': bio, + }; + } +} diff --git a/apps/eva_mobile/lib/features/care/data/repositories/care_repository_impl.dart b/apps/eva_mobile/lib/features/care/data/repositories/care_repository_impl.dart new file mode 100644 index 0000000..6033c06 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/repositories/care_repository_impl.dart @@ -0,0 +1,52 @@ +import '../../domain/repositories/care_repository.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../../domain/entities/clinic_entity.dart'; +import '../../domain/entities/community_status_entity.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../datasources/care_datasource.dart'; + +class CareRepositoryImpl implements CareRepository { + final CareDataSource _dataSource; + + CareRepositoryImpl(this._dataSource); + + @override + Future> getPsychologists({String? category, String? query}) { + return _dataSource.getPsychologists(category: category, query: query); + } + + @override + Future> getClinics({String? query}) { + return _dataSource.getClinics(query: query); + } + + @override + Future getCommunityStatus() { + return _dataSource.getCommunityStatus(); + } + + @override + Future joinCommunityWithVipTrial() { + return _dataSource.joinCommunityWithVipTrial(); + } + + @override + Future> getConsultationSessions() { + return _dataSource.getConsultationSessions(); + } + + @override + Future bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) { + return _dataSource.bookConsultation( + psychologistId: psychologistId, + preferredDateTime: preferredDateTime, + sessionType: sessionType, + complaints: complaints, + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/data/repositories/fallback_care_repository.dart b/apps/eva_mobile/lib/features/care/data/repositories/fallback_care_repository.dart new file mode 100644 index 0000000..24990d0 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/data/repositories/fallback_care_repository.dart @@ -0,0 +1,86 @@ +import '../../domain/repositories/care_repository.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../../domain/entities/clinic_entity.dart'; +import '../../domain/entities/community_status_entity.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackCareRepository implements CareRepository { + final CareRepository _remote; + final CareRepository _mock; + + FallbackCareRepository({ + required CareRepository remote, + required CareRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future> getPsychologists({String? category, String? query}) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getPsychologists(category: category, query: query), + mockCall: () => _mock.getPsychologists(category: category, query: query), + operationName: 'getPsychologists', + ); + } + + @override + Future> getClinics({String? query}) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getClinics(query: query), + mockCall: () => _mock.getClinics(query: query), + operationName: 'getClinics', + ); + } + + @override + Future getCommunityStatus() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getCommunityStatus(), + mockCall: () => _mock.getCommunityStatus(), + operationName: 'getCommunityStatus', + ); + } + + @override + Future joinCommunityWithVipTrial() { + return FallbackExecutor.execute( + remoteCall: () => _remote.joinCommunityWithVipTrial(), + mockCall: () => _mock.joinCommunityWithVipTrial(), + operationName: 'joinCommunityWithVipTrial', + ); + } + + @override + Future> getConsultationSessions() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getConsultationSessions(), + mockCall: () => _mock.getConsultationSessions(), + operationName: 'getConsultationSessions', + ); + } + + @override + Future bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) { + return FallbackExecutor.execute( + remoteCall: () => _remote.bookConsultation( + psychologistId: psychologistId, + preferredDateTime: preferredDateTime, + sessionType: sessionType, + complaints: complaints, + ), + mockCall: () => _mock.bookConsultation( + psychologistId: psychologistId, + preferredDateTime: preferredDateTime, + sessionType: sessionType, + complaints: complaints, + ), + operationName: 'bookConsultation', + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/domain/entities/clinic_entity.dart b/apps/eva_mobile/lib/features/care/domain/entities/clinic_entity.dart new file mode 100644 index 0000000..37b8e5e --- /dev/null +++ b/apps/eva_mobile/lib/features/care/domain/entities/clinic_entity.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class ClinicEntity { + final String id; + final String name; + final String address; + final String city; + final double distanceKm; + final String photoUrl; + final String phone; + final List facilities; + final double rating; + + const ClinicEntity({ + required this.id, + required this.name, + required this.address, + required this.city, + required this.distanceKm, + required this.photoUrl, + required this.phone, + required this.facilities, + this.rating = 4.8, + }); +} diff --git a/apps/eva_mobile/lib/features/care/domain/entities/community_status_entity.dart b/apps/eva_mobile/lib/features/care/domain/entities/community_status_entity.dart new file mode 100644 index 0000000..5086be4 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/domain/entities/community_status_entity.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class CommunityStatusEntity { + final bool isMember; + final bool isVipActive; + final int vipRemainingDays; + final DateTime? joinedAt; + final String serverName; + final String inviteUrl; + final int memberCount; + + const CommunityStatusEntity({ + required this.isMember, + required this.isVipActive, + this.vipRemainingDays = 0, + this.joinedAt, + this.serverName = 'Eva Safe Space ID', + this.inviteUrl = 'https://discord.gg/evasafespace', + this.memberCount = 1420, + }); + + CommunityStatusEntity copyWith({ + bool? isMember, + bool? isVipActive, + int? vipRemainingDays, + DateTime? joinedAt, + String? serverName, + String? inviteUrl, + int? memberCount, + }) { + return CommunityStatusEntity( + isMember: isMember ?? this.isMember, + isVipActive: isVipActive ?? this.isVipActive, + vipRemainingDays: vipRemainingDays ?? this.vipRemainingDays, + joinedAt: joinedAt ?? this.joinedAt, + serverName: serverName ?? this.serverName, + inviteUrl: inviteUrl ?? this.inviteUrl, + memberCount: memberCount ?? this.memberCount, + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/domain/entities/consultation_session_entity.dart b/apps/eva_mobile/lib/features/care/domain/entities/consultation_session_entity.dart new file mode 100644 index 0000000..11f86d0 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/domain/entities/consultation_session_entity.dart @@ -0,0 +1,41 @@ +import 'package:flutter/foundation.dart'; +import 'psychologist_entity.dart'; + +enum ConsultationStatus { + upcoming, + completed, + cancelled, +} + +enum ConsultationType { + onlineVideo, + offlineClinic, +} + +@immutable +class ConsultationSessionEntity { + final String id; + final PsychologistEntity psychologist; + final DateTime sessionDateTime; + final ConsultationStatus status; + final ConsultationType type; + final String? meetingUrl; + final String? locationName; + final String? notes; + final String? diagnosisSummary; + + const ConsultationSessionEntity({ + required this.id, + required this.psychologist, + required this.sessionDateTime, + required this.status, + this.type = ConsultationType.onlineVideo, + this.meetingUrl, + this.locationName, + this.notes, + this.diagnosisSummary, + }); + + bool get isUpcoming => status == ConsultationStatus.upcoming; + bool get isCompleted => status == ConsultationStatus.completed; +} diff --git a/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart b/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart new file mode 100644 index 0000000..97f9256 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart @@ -0,0 +1,38 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class PsychologistEntity { + final String id; + final String name; + final String title; + final String registrationNumber; // STR or SIPP + final String avatarUrl; + final double rating; + final int reviewCount; + final int experienceYears; + final List specialties; + final int feePerSession; // in IDR + final int durationMinutes; + final bool availableOnline; + final String? clinicAffiliation; + final String bio; + + const PsychologistEntity({ + required this.id, + required this.name, + required this.title, + required this.registrationNumber, + required this.avatarUrl, + required this.rating, + required this.reviewCount, + required this.experienceYears, + required this.specialties, + required this.feePerSession, + this.durationMinutes = 50, + this.availableOnline = true, + this.clinicAffiliation, + required this.bio, + }); + + bool get isPsychiatrist => title.toLowerCase().contains('sp.kj'); +} diff --git a/apps/eva_mobile/lib/features/care/domain/repositories/care_repository.dart b/apps/eva_mobile/lib/features/care/domain/repositories/care_repository.dart new file mode 100644 index 0000000..35a1616 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/domain/repositories/care_repository.dart @@ -0,0 +1,32 @@ +import '../entities/psychologist_entity.dart'; +import '../entities/clinic_entity.dart'; +import '../entities/community_status_entity.dart'; +import '../entities/consultation_session_entity.dart'; + +abstract class CareRepository { + /// Mendapatkan daftar psikolog & psikiater dengan opsi filter kategori dan pencarian nama. + Future> getPsychologists({ + String? category, // 'all', 'psychologist', 'psychiatrist' + String? query, + }); + + /// Mendapatkan daftar klinik rekanan. + Future> getClinics({String? query}); + + /// Mendapatkan status keanggotaan komunitas Discord pengguna. + Future getCommunityStatus(); + + /// Memproses pembelian tiket masuk komunitas sekali bayar + 1 bulan trial VIP event. + Future joinCommunityWithVipTrial(); + + /// Mendapatkan riwayat dan jadwal sesi konsultasi pengguna. + Future> getConsultationSessions(); + + /// Melakukan booking sesi konsultasi baru. + Future bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }); +} diff --git a/apps/eva_mobile/lib/features/care/presentation/providers/care_provider.dart b/apps/eva_mobile/lib/features/care/presentation/providers/care_provider.dart new file mode 100644 index 0000000..78d207d --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/providers/care_provider.dart @@ -0,0 +1,140 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../config/app_config.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../../domain/entities/clinic_entity.dart'; +import '../../domain/entities/community_status_entity.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../../domain/repositories/care_repository.dart'; +import '../../data/datasources/mock_care_datasource.dart'; +import '../../data/datasources/remote_care_datasource.dart'; +import '../../data/repositories/care_repository_impl.dart'; +import '../../data/repositories/fallback_care_repository.dart'; + +// Persistent in-memory mock instance across provider updates +final _sharedMockCareDataSource = MockCareDataSource(); + +final careRepositoryProvider = Provider((ref) { + final mockRepo = CareRepositoryImpl(_sharedMockCareDataSource); + final remoteRepo = CareRepositoryImpl(RemoteCareDataSource()); + + if (AppConfig.useMockData) { + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackCareRepository(remote: remoteRepo, mock: mockRepo); + } else { + return remoteRepo; + } +}); + +// Category Filter: 'all', 'psychologist', 'psychiatrist', 'clinic' +final careCategoryFilterProvider = StateProvider((ref) => 'all'); + +// Search Query Filter +final careSearchQueryProvider = StateProvider((ref) => ''); + +// Psychologists & Psychiatrists Provider +final psychologistsListProvider = FutureProvider>((ref) async { + final repo = ref.watch(careRepositoryProvider); + final category = ref.watch(careCategoryFilterProvider); + final query = ref.watch(careSearchQueryProvider); + + // If the filter is explicitly 'clinic', psychologists list can be empty or unchanged + if (category == 'clinic') return []; + + return repo.getPsychologists( + category: category == 'all' ? null : category, + query: query, + ); +}); + +// Clinics Provider +final clinicsListProvider = FutureProvider>((ref) async { + final repo = ref.watch(careRepositoryProvider); + final query = ref.watch(careSearchQueryProvider); + return repo.getClinics(query: query); +}); + +// Community Status Notifier (Handles instant upgrade & state sync) +class CommunityStatusNotifier extends StateNotifier> { + final CareRepository _repo; + + CommunityStatusNotifier(this._repo) : super(const AsyncValue.loading()) { + loadStatus(); + } + + Future loadStatus() async { + state = const AsyncValue.loading(); + try { + final status = await _repo.getCommunityStatus(); + state = AsyncValue.data(status); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future joinWithVipTrial() async { + try { + final updated = await _repo.joinCommunityWithVipTrial(); + state = AsyncValue.data(updated); + return true; + } catch (e, st) { + state = AsyncValue.error(e, st); + return false; + } + } +} + +final communityStatusProvider = + StateNotifierProvider>((ref) { + final repo = ref.watch(careRepositoryProvider); + return CommunityStatusNotifier(repo); +}); + +// Consultation Sessions Notifier (Manages upcoming & completed history) +class ConsultationSessionsNotifier + extends StateNotifier>> { + final CareRepository _repo; + + ConsultationSessionsNotifier(this._repo) : super(const AsyncValue.loading()) { + loadSessions(); + } + + Future loadSessions() async { + state = const AsyncValue.loading(); + try { + final sessions = await _repo.getConsultationSessions(); + state = AsyncValue.data(sessions); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + Future bookNewSession({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) async { + try { + final newSession = await _repo.bookConsultation( + psychologistId: psychologistId, + preferredDateTime: preferredDateTime, + sessionType: sessionType, + complaints: complaints, + ); + + final current = state.value ?? []; + state = AsyncValue.data([newSession, ...current]); + return true; + } catch (e) { + return false; + } + } +} + +final consultationSessionsProvider = StateNotifierProvider< + ConsultationSessionsNotifier, + AsyncValue>>((ref) { + final repo = ref.watch(careRepositoryProvider); + return ConsultationSessionsNotifier(repo); +}); diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/care_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/care_screen.dart new file mode 100644 index 0000000..92522b6 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/care_screen.dart @@ -0,0 +1,539 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../config/app_config.dart'; +import '../../../../theme/app_colors.dart'; +import '../../../auth/presentation/screens/profile_screen.dart'; +import '../../../home/presentation/widgets/profile_dropdown_modal.dart'; +import '../providers/care_provider.dart'; +import '../widgets/community_banner_card.dart'; +import '../widgets/community_paywall_sheet.dart'; +import '../widgets/psychologist_card.dart'; +import '../widgets/clinic_card.dart'; +import '../widgets/consultation_history_card.dart'; +import '../widgets/booking_consultation_sheet.dart'; + +class CareScreen extends ConsumerStatefulWidget { + const CareScreen({super.key}); + + @override + ConsumerState createState() => CareScreenState(); +} + +class CareScreenState extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + final GlobalKey _profileButtonKey = GlobalKey(); + OverlayEntry? _profileOverlayEntry; + + String _userName = 'Pengguna EVA'; + String _userEmail = 'user@eva.ai'; + + @override + void initState() { + super.initState(); + _loadUserInfo(); + } + + @override + void dispose() { + _searchController.dispose(); + _removeProfileOverlay(); + super.dispose(); + } + + Future _loadUserInfo() async { + final name = await AppConfig.getUserName(); + final email = await AppConfig.getUserEmail(); + if (mounted) { + setState(() { + if (name != null && name.isNotEmpty) _userName = name; + if (email != null && email.isNotEmpty) _userEmail = email; + }); + } + } + + String get _userInitials { + final parts = _userName.trim().split(RegExp(r'\s+')); + if (parts.length >= 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } else if (parts.isNotEmpty && parts[0].isNotEmpty) { + return parts[0].substring(0, parts[0].length >= 2 ? 2 : 1).toUpperCase(); + } + return 'EV'; + } + + void _removeProfileOverlay() { + _profileOverlayEntry?.remove(); + _profileOverlayEntry = null; + } + + void _toggleProfileDropdown() { + if (_profileOverlayEntry != null) { + _removeProfileOverlay(); + return; + } + + final renderBox = _profileButtonKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + + _profileOverlayEntry = OverlayEntry( + builder: (context) => Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _removeProfileOverlay, + ), + ), + Positioned( + top: offset.dy + size.height + 8, + right: 16, + child: Material( + color: Colors.transparent, + child: ProfileDropdownModal( + userName: _userName, + userEmail: _userEmail, + onProfileTap: () { + _removeProfileOverlay(); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProfileScreen()), + ); + }, + onEmergencyContactsTap: () { + _removeProfileOverlay(); + Navigator.pushNamed(context, '/emergency-contacts'); + }, + onSettingsTap: () { + _removeProfileOverlay(); + Navigator.pushNamed(context, '/settings'); + }, + onLogoutTap: () { + _removeProfileOverlay(); + Navigator.pushReplacementNamed(context, '/login'); + }, + ), + ), + ), + ], + ), + ); + + Overlay.of(context).insert(_profileOverlayEntry!); + } + + void _handleOpenDiscord() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + title: Row( + children: [ + const Icon(Icons.forum, color: Color(0xFF5865F2)), + const SizedBox(width: 8), + Text( + 'Discord Community', + style: GoogleFonts.plusJakartaSans(fontSize: 16, fontWeight: FontWeight.w700), + ), + ], + ), + content: Text( + 'Tautan invite server: https://discord.gg/evasafespace\n\nPastikan akun Discord Anda sudah masuk untuk menikmati channel obrolan & role VIP.', + style: GoogleFonts.plusJakartaSans(fontSize: 13, color: AppColors.textPrimary, height: 1.4), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text('Tutup', style: GoogleFonts.plusJakartaSans(fontWeight: FontWeight.w700)), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final activeFilter = ref.watch(careCategoryFilterProvider); + final communityStatusAsync = ref.watch(communityStatusProvider); + final sessionsAsync = ref.watch(consultationSessionsProvider); + final psychologistsAsync = ref.watch(psychologistsListProvider); + final clinicsAsync = ref.watch(clinicsListProvider); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: () async { + ref.invalidate(psychologistsListProvider); + ref.invalidate(clinicsListProvider); + ref.read(communityStatusProvider.notifier).loadStatus(); + ref.read(consultationSessionsProvider.notifier).loadSessions(); + }, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // 1. Custom Header & Profile Avatar + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Layanan & Komunitas', + style: GoogleFonts.plusJakartaSans( + fontSize: 22, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'Bantuan klinis profesional & safe space', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + GestureDetector( + key: _profileButtonKey, + onTap: _toggleProfileDropdown, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primaryContainer, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.12), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Center( + child: Text( + _userInitials, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ), + ), + ], + ), + ), + ), + + // 2. Banner Komunitas Discord (Dynamic State) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: communityStatusAsync.when( + data: (status) => CommunityBannerCard( + status: status, + onOpenPaywall: () => CommunityPaywallSheet.show(context), + onOpenDiscord: _handleOpenDiscord, + ), + loading: () => Container( + height: 140, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ), + ), + error: (error, stack) => const SizedBox(), + ), + ), + ), + + // 3. Section Sesi & Riwayat Konsultasi (Jika ada) + sessionsAsync.when( + data: (sessions) { + if (sessions.isEmpty) return const SliverToBoxAdapter(child: SizedBox()); + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Jadwal & Riwayat Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + '${sessions.length} Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 10), + ConsultationHistoryCard( + session: sessions.first, + onActionTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Membuka sesi: ${sessions.first.meetingUrl ?? "Klinik"}', + ), + backgroundColor: AppColors.primary, + ), + ); + }, + onRebookTap: () { + BookingConsultationSheet.show( + context, + sessions.first.psychologist, + ); + }, + ), + ], + ), + ), + ); + }, + loading: () => const SliverToBoxAdapter(child: SizedBox()), + error: (error, stack) => const SliverToBoxAdapter(child: SizedBox()), + ), + + // 4. Search Bar & Filter Chips + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Temukan Tenaga Profesional & Faskes', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + + // Search TextField + TextField( + controller: _searchController, + onChanged: (val) { + ref.read(careSearchQueryProvider.notifier).state = val; + }, + decoration: InputDecoration( + hintText: 'Cari nama psikolog, spesialisasi, atau klinik...', + hintStyle: GoogleFonts.plusJakartaSans( + fontSize: 13, + color: AppColors.textLight, + ), + prefixIcon: const Icon(Icons.search, color: AppColors.primary), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + _searchController.clear(); + ref.read(careSearchQueryProvider.notifier).state = ''; + }, + ) + : null, + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + const SizedBox(height: 12), + + // Filter Chips + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildFilterChip('Semua', 'all', activeFilter), + const SizedBox(width: 8), + _buildFilterChip('Psikolog', 'psychologist', activeFilter), + const SizedBox(width: 8), + _buildFilterChip('Psikiater', 'psychiatrist', activeFilter), + const SizedBox(width: 8), + _buildFilterChip('Klinik Mitra', 'clinic', activeFilter), + ], + ), + ), + ], + ), + ), + ), + + // 5. Main Directory List (Psychologists or Clinics) + if (activeFilter == 'clinic') ...[ + clinicsAsync.when( + data: (clinics) { + if (clinics.isEmpty) { + return _buildEmptyState('Tidak ada klinik mitra yang cocok'); + } + return SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 100), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final clinic = clinics[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: ClinicCard( + clinic: clinic, + onDetailsTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Menghubungi ${clinic.name} (${clinic.phone})'), + backgroundColor: AppColors.primary, + ), + ); + }, + ), + ); + }, + childCount: clinics.length, + ), + ), + ); + }, + loading: () => const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator(color: AppColors.primary)), + ), + ), + error: (err, _) => SliverToBoxAdapter( + child: Center(child: Text('Gagal memuat klinik: $err')), + ), + ), + ] else ...[ + psychologistsAsync.when( + data: (psychologists) { + if (psychologists.isEmpty) { + return _buildEmptyState('Tidak ada psikolog atau psikiater yang cocok'); + } + return SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 100), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final psychologist = psychologists[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: PsychologistCard( + psychologist: psychologist, + onBookTap: () { + BookingConsultationSheet.show(context, psychologist); + }, + ), + ); + }, + childCount: psychologists.length, + ), + ), + ); + }, + loading: () => const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator(color: AppColors.primary)), + ), + ), + error: (err, _) => SliverToBoxAdapter( + child: Center(child: Text('Gagal memuat psikolog: $err')), + ), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildFilterChip(String label, String key, String activeKey) { + final isSelected = activeKey == key; + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (_) { + ref.read(careCategoryFilterProvider.notifier).state = key; + }, + selectedColor: AppColors.primary, + backgroundColor: Colors.white, + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isSelected ? AppColors.primary : const Color(0xFFE2E8F0), + ), + ), + ); + } + + Widget _buildEmptyState(String message) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20), + child: Column( + children: [ + Icon(Icons.search_off_rounded, size: 48, color: Colors.grey.shade400), + const SizedBox(height: 12), + Text( + message, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/booking_consultation_sheet.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/booking_consultation_sheet.dart new file mode 100644 index 0000000..8d83fae --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/booking_consultation_sheet.dart @@ -0,0 +1,459 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../providers/care_provider.dart'; + +class BookingConsultationSheet extends ConsumerStatefulWidget { + final PsychologistEntity psychologist; + + const BookingConsultationSheet({ + super.key, + required this.psychologist, + }); + + static Future show(BuildContext context, PsychologistEntity psychologist) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => BookingConsultationSheet(psychologist: psychologist), + ); + } + + @override + ConsumerState createState() => _BookingConsultationSheetState(); +} + +class _BookingConsultationSheetState extends ConsumerState { + ConsultationType _selectedType = ConsultationType.onlineVideo; + int _selectedDayOffset = 1; // 1 = Besok, 2 = Lusa + int _selectedTimeSlot = 0; // Index in slots + final TextEditingController _notesController = TextEditingController(); + bool _isLoading = false; + + final List _timeSlots = [ + '09:00 WIB', + '13:30 WIB', + '16:00 WIB', + '19:30 WIB', + ]; + + @override + void dispose() { + _notesController.dispose(); + super.dispose(); + } + + Future _handleConfirmBooking() async { + setState(() => _isLoading = true); + + final targetDate = DateTime.now().add(Duration(days: _selectedDayOffset)); + final slotParts = _timeSlots[_selectedTimeSlot].split(' ')[0].split(':'); + final hour = int.parse(slotParts[0]); + final minute = int.parse(slotParts[1]); + + final finalDateTime = DateTime( + targetDate.year, + targetDate.month, + targetDate.day, + hour, + minute, + ); + + final success = await ref.read(consultationSessionsProvider.notifier).bookNewSession( + psychologistId: widget.psychologist.id, + preferredDateTime: finalDateTime, + sessionType: _selectedType, + complaints: _notesController.text.trim(), + ); + + if (!mounted) return; + setState(() => _isLoading = false); + + if (success) { + Navigator.pop(context); + _showSuccessDialog(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Gagal menjadwalkan sesi. Silakan coba kembali.'), + backgroundColor: AppColors.error, + ), + ); + } + } + + void _showSuccessDialog() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + contentPadding: const EdgeInsets.all(22), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 58, + height: 58, + decoration: const BoxDecoration( + color: Color(0xFFDCFCE7), + shape: BoxShape.circle, + ), + child: const Icon(Icons.event_available, color: Color(0xFF16A34A), size: 34), + ), + const SizedBox(height: 14), + Text( + 'Jadwal Berhasil Dibuat!', + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'Sesi konsultasi Anda bersama ${widget.psychologist.name} telah tercatat di Jadwal & Riwayat.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 18), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 11), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + 'Selesai', + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 12, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + + // Psychologist Summary Header + Row( + children: [ + CircleAvatar( + radius: 26, + backgroundColor: AppColors.primaryContainer, + backgroundImage: NetworkImage(widget.psychologist.avatarUrl), + onBackgroundImageError: (exception, stackTrace) {}, + child: Text( + widget.psychologist.name.substring(0, 2).toUpperCase(), + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + widget.psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + + // Mode Sesi Selector + Text( + 'Pilih Moda Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _buildTypeOption( + title: 'Online Video', + subtitle: 'Via Google Meet', + icon: Icons.videocam_outlined, + type: ConsultationType.onlineVideo, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildTypeOption( + title: 'Tatap Muka', + subtitle: widget.psychologist.clinicAffiliation ?? 'Klinik Mitra', + icon: Icons.location_city_outlined, + type: ConsultationType.offlineClinic, + ), + ), + ], + ), + const SizedBox(height: 18), + + // Tanggal Sesi + Text( + 'Pilih Hari', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + _buildDayChip('Besok', 1), + const SizedBox(width: 8), + _buildDayChip('Lusa', 2), + const SizedBox(width: 8), + _buildDayChip('3 Hari Lagi', 3), + ], + ), + const SizedBox(height: 16), + + // Pilihan Jam Sesi + Text( + 'Pilih Jam Sesi (Durasi ${widget.psychologist.durationMinutes} menit)', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(_timeSlots.length, (index) { + final isSelected = _selectedTimeSlot == index; + return ChoiceChip( + label: Text(_timeSlots[index]), + selected: isSelected, + onSelected: (_) => setState(() => _selectedTimeSlot = index), + selectedColor: AppColors.primary, + backgroundColor: const Color(0xFFF1F5F9), + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isSelected ? AppColors.primary : Colors.transparent, + ), + ), + ); + }), + ), + const SizedBox(height: 16), + + // Catatan Singkat + Text( + 'Fokus atau Cerita Singkat (Opsional)', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + TextField( + controller: _notesController, + maxLines: 2, + decoration: InputDecoration( + hintText: 'Misal: Sedang merasa cemas menghadapi sidang/pekerjaan...', + hintStyle: GoogleFonts.plusJakartaSans(fontSize: 12, color: AppColors.textLight), + filled: true, + fillColor: const Color(0xFFF8FAFC), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + ), + ), + const SizedBox(height: 20), + + // CTA Button + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleConfirmBooking, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: _isLoading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : Text( + 'Konfirmasi Janji Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTypeOption({ + required String title, + required String subtitle, + required IconData icon, + required ConsultationType type, + }) { + final isSelected = _selectedType == type; + + return GestureDetector( + onTap: () => setState(() => _selectedType = type), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryContainer.withValues(alpha: 0.5) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFE2E8F0), + width: isSelected ? 1.5 : 1, + ), + ), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: isSelected ? AppColors.primary : AppColors.textSecondary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: isSelected ? AppColors.primary : AppColors.textPrimary, + ), + ), + Text( + subtitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildDayChip(String label, int offset) { + final isSelected = _selectedDayOffset == offset; + + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (_) => setState(() => _selectedDayOffset = offset), + selectedColor: AppColors.primary, + backgroundColor: const Color(0xFFF1F5F9), + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isSelected ? AppColors.primary : Colors.transparent, + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_card.dart new file mode 100644 index 0000000..c50c6c4 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_card.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/clinic_entity.dart'; + +class ClinicCard extends StatelessWidget { + final ClinicEntity clinic; + final VoidCallback onDetailsTap; + + const ClinicCard({ + super.key, + required this.clinic, + required this.onDetailsTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFEAEBFA)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Clinic Image & Distance Badge + Stack( + children: [ + Image.network( + clinic.photoUrl, + height: 120, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + height: 120, + color: AppColors.primaryContainer, + child: const Center( + child: Icon(Icons.local_hospital, color: AppColors.primary, size: 36), + ), + ), + ), + Positioned( + top: 10, + right: 10, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.near_me, color: Colors.white, size: 12), + const SizedBox(width: 4), + Text( + '${clinic.distanceKm} km', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + clinic.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Row( + children: [ + const Icon(Icons.star, size: 14, color: Color(0xFFF59E0B)), + const SizedBox(width: 3), + Text( + clinic.rating.toStringAsFixed(1), + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.location_on_outlined, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 4), + Expanded( + child: Text( + clinic.address, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Facilities Chips + Wrap( + spacing: 6, + runSpacing: 4, + children: clinic.facilities.take(3).map((facility) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + facility, + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + color: const Color(0xFF475569), + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 12), + + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: onDetailsTap, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + icon: const Icon(Icons.call_outlined, size: 15), + label: Text( + 'Hubungi & Reservasi Offline', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/community_banner_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/community_banner_card.dart new file mode 100644 index 0000000..33d940d --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/community_banner_card.dart @@ -0,0 +1,392 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/community_status_entity.dart'; + +class CommunityBannerCard extends StatelessWidget { + final CommunityStatusEntity status; + final VoidCallback onOpenPaywall; + final VoidCallback onOpenDiscord; + + const CommunityBannerCard({ + super.key, + required this.status, + required this.onOpenPaywall, + required this.onOpenDiscord, + }); + + @override + Widget build(BuildContext context) { + if (!status.isMember) { + return _buildNonMemberBanner(context); + } else if (status.isVipActive) { + return _buildVipActiveBanner(context); + } else { + return _buildRegularMemberBanner(context); + } + } + + Widget _buildNonMemberBanner(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF5865F2), // Discord Blurple + Color(0xFF4752C4), + Color(0xFF353985), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5865F2).withValues(alpha: 0.3), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.people_alt, color: Colors.white, size: 14), + const SizedBox(width: 6), + Text( + 'KOMUNITAS SAFE SPACE', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFEF08A), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '1 Bln VIP Trial', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFF854D0E), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Ruang Aman untuk Saling Bercerita & Berproses Bersama', + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w700, + color: Colors.white, + height: 1.3, + ), + ), + const SizedBox(height: 6), + Text( + 'Sekali bayar untuk masuk server seumur hidup. Nikmati bonus 1 bulan gratis tiket live sharing session & webinar bersama psikolog.', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.white.withValues(alpha: 0.9), + height: 1.4, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Biaya Masuk', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: Colors.white.withValues(alpha: 0.75), + ), + ), + Text( + 'Rp 49.000', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ], + ), + const Spacer(), + ElevatedButton.icon( + onPressed: onOpenPaywall, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF4752C4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + icon: const Icon(Icons.star, size: 16, color: Color(0xFFEAB308)), + label: Text( + 'Gabung Sekarang', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildVipActiveBanner(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF6366F1), + Color(0xFF4F46E5), + Color(0xFF4338CA), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color(0xFF6366F1).withValues(alpha: 0.3), + blurRadius: 16, + offset: const Offset(0, 5), + ), + ], + ), + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFFDE047), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + children: [ + const Icon(Icons.verified, size: 14, color: Color(0xFF713F12)), + const SizedBox(width: 4), + Text( + 'VIP EVENT AKTIF', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFF713F12), + ), + ), + ], + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Sisa ${status.vipRemainingDays} Hari', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Kamu adalah Bagian dari Eva Safe Space', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + 'Tiket VIP Event aktif: kamu dapat bergabung di webinar mingguan dan room sharing bersama psikolog di Discord.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: Colors.white.withValues(alpha: 0.9), + ), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: onOpenDiscord, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white, width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + icon: const Icon(Icons.forum_outlined, size: 18), + label: Text( + 'Buka Komunitas Discord', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildRegularMemberBanner(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Member Server Aktif', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + const Spacer(), + Text( + 'VIP Trial Berakhir', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textLight, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Ingin Ikut Live Webinar Psikolog Bulan Ini?', + style: GoogleFonts.plusJakartaSans( + fontSize: 14.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Akses server Discord tetap berlaku seumur hidup. Perpanjang VIP Event Pass untuk join sesi tanya jawab langsung.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onOpenDiscord, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textPrimary, + side: BorderSide(color: Colors.grey.shade300), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + 'Buka Discord', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: ElevatedButton( + onPressed: onOpenPaywall, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), + child: Text( + 'Perpanjang VIP', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/community_paywall_sheet.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/community_paywall_sheet.dart new file mode 100644 index 0000000..c1c36bf --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/community_paywall_sheet.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../providers/care_provider.dart'; + +class CommunityPaywallSheet extends ConsumerStatefulWidget { + const CommunityPaywallSheet({super.key}); + + static Future show(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const CommunityPaywallSheet(), + ); + } + + @override + ConsumerState createState() => _CommunityPaywallSheetState(); +} + +class _CommunityPaywallSheetState extends ConsumerState { + bool _isLoading = false; + + Future _handleJoin() async { + setState(() => _isLoading = true); + + final success = await ref.read(communityStatusProvider.notifier).joinWithVipTrial(); + + if (!mounted) return; + setState(() => _isLoading = false); + + if (success) { + Navigator.pop(context); + _showSuccessDialog(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Terjadi kendala saat memproses pendaftaran. Coba lagi.'), + backgroundColor: AppColors.error, + ), + ); + } + } + + void _showSuccessDialog() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + contentPadding: const EdgeInsets.all(24), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + color: Color(0xFFDCFCE7), + shape: BoxShape.circle, + ), + child: const Icon(Icons.check_circle, color: Color(0xFF16A34A), size: 38), + ), + const SizedBox(height: 16), + Text( + 'Selamat Bergabung!', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'Akses Server Komunitas dan 1 Bulan VIP Event Trial Anda telah aktif. Tautan undangan telah disiapkan.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.forum, color: Colors.white, size: 18), + label: Text( + 'Buka Discord Sekarang', + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 12, + bottom: MediaQuery.of(context).viewInsets.bottom + 28, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + + // Header Tag & Badge + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF5865F2).withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon(Icons.diversity_1, color: Color(0xFF5865F2), size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Eva Safe Space Community', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Komunitas Discord Pendukung Kesehatan Mental', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // Value Proposition Box + Container( + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.15)), + ), + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildBenefitRow( + icon: Icons.lock_clock, + title: 'Akses Sekali Bayar Seumur Hidup', + subtitle: 'Cukup bayar sekali untuk bergabung ke server obrolan komunitas tanpa langganan wajib.', + ), + const Divider(height: 20, color: Color(0x1A5358CB)), + _buildBenefitRow( + icon: Icons.workspace_premium, + title: 'Bonus 1 Bulan VIP Event Trial', + subtitle: 'Ikuti webinar mingguan interaktif, tanya jawab langsung dengan psikolog, & sharing session terfasilitasi.', + ), + const Divider(height: 20, color: Color(0x1A5358CB)), + _buildBenefitRow( + icon: Icons.shield, + title: 'Lingkungan Aman & Termoderasi', + subtitle: 'Bebas stigma dan di bawah supervisi konselor Eva untuk menjaga kehangatan safe space.', + ), + const Divider(height: 20, color: Color(0x1A5358CB)), + _buildBenefitRow( + icon: Icons.masks_outlined, + title: 'Privasi & Opsi Nama Samaran', + subtitle: 'Kamu bebas berekspresi secara anonim/pseudonim tanpa rasa cemas privasi.', + ), + ], + ), + ), + const SizedBox(height: 20), + + // Price Summary Box + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tiket Masuk + 1 Bln VIP Pass', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + 'Rp 49.000', + style: GoogleFonts.plusJakartaSans( + fontSize: 20, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFDCFCE7), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Sekali Bayar', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF166534), + ), + ), + ), + ], + ), + ], + ), + const Spacer(), + const Icon(Icons.verified_user_outlined, color: AppColors.primary, size: 28), + ], + ), + ), + const SizedBox(height: 20), + + // Action Button + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleJoin, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Gabung Sekarang (Rp 49.000)', + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward, size: 18), + ], + ), + ), + ), + const SizedBox(height: 10), + Center( + child: Text( + 'Pembayaran aman via QRIS, GoPay, OVO, & Transfer Bank', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textLight, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildBenefitRow({ + required IconData icon, + required String title, + required String subtitle, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 18, color: AppColors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_history_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_history_card.dart new file mode 100644 index 0000000..7d0ad6c --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_history_card.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/consultation_session_entity.dart'; + +class ConsultationHistoryCard extends StatelessWidget { + final ConsultationSessionEntity session; + final VoidCallback? onActionTap; + final VoidCallback? onRebookTap; + + const ConsultationHistoryCard({ + super.key, + required this.session, + this.onActionTap, + this.onRebookTap, + }); + + String _formatDateTime(DateTime dt) { + final months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', + 'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des' + ]; + final day = dt.day; + final month = months[dt.month - 1]; + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$day $month ${dt.year}, $hour:$minute WIB'; + } + + @override + Widget build(BuildContext context) { + final isUpcoming = session.isUpcoming; + + return Container( + decoration: BoxDecoration( + color: isUpcoming ? const Color(0xFFF0FDF4) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isUpcoming ? const Color(0xFFBBF7D0) : const Color(0xFFE2E8F0), + width: 1, + ), + ), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isUpcoming ? const Color(0xFFDCFCE7) : const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isUpcoming ? Icons.event_available : Icons.history, + size: 13, + color: isUpcoming ? const Color(0xFF166534) : const Color(0xFF475569), + ), + const SizedBox(width: 4), + Text( + isUpcoming ? 'SESI MENDATANG' : 'SELESAI', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w800, + color: isUpcoming ? const Color(0xFF166534) : const Color(0xFF475569), + ), + ), + ], + ), + ), + const Spacer(), + Text( + _formatDateTime(session.sessionDateTime), + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 10), + + // Doctor Info Row + Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: AppColors.primaryContainer, + backgroundImage: NetworkImage(session.psychologist.avatarUrl), + onBackgroundImageError: (exception, stackTrace) {}, + child: Text( + session.psychologist.name.substring(0, 2).toUpperCase(), + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + fontSize: 12, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + session.type == ConsultationType.onlineVideo + ? 'Sesi Konseling Video Call' + : 'Tatap Muka di ${session.locationName ?? "Klinik"}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: isUpcoming ? Colors.white : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isUpcoming ? const Color(0xFFDCFCE7) : const Color(0xFFE2E8F0), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.sticky_note_2_outlined, size: 14, color: AppColors.primary), + const SizedBox(width: 6), + Expanded( + child: Text( + session.notes!, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textPrimary, + height: 1.35, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 12), + + // Action Button + if (isUpcoming) ...[ + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: onActionTap, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF16A34A), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(vertical: 8), + elevation: 0, + ), + icon: const Icon(Icons.video_call, size: 18), + label: Text( + 'Masuk Ruang Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ] else ...[ + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: onRebookTap, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + icon: const Icon(Icons.refresh, size: 15), + label: Text( + 'Konsultasi Lanjutan', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_card.dart new file mode 100644 index 0000000..7db00a8 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_card.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/psychologist_entity.dart'; + +class PsychologistCard extends StatelessWidget { + final PsychologistEntity psychologist; + final VoidCallback onBookTap; + + const PsychologistCard({ + super.key, + required this.psychologist, + required this.onBookTap, + }); + + String _formatCurrency(int amount) { + final str = amount.toString(); + final buffer = StringBuffer(); + int count = 0; + for (int i = str.length - 1; i >= 0; i--) { + buffer.write(str[i]); + count++; + if (count % 3 == 0 && i != 0) { + buffer.write('.'); + } + } + return 'Rp ${buffer.toString().split('').reversed.join('')}'; + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFEAEBFA)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.05), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar with Online indicator + Stack( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: AppColors.primaryContainer, + backgroundImage: NetworkImage(psychologist.avatarUrl), + onBackgroundImageError: (exception, stackTrace) {}, + child: Text( + psychologist.name.substring(0, 2).toUpperCase(), + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + if (psychologist.availableOnline) + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 13, + height: 13, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 14), + + // Name, Title, and Verification Badge + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFBFDBFE)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.verified, size: 12, color: Color(0xFF2563EB)), + const SizedBox(width: 4), + Text( + psychologist.isPsychiatrist ? 'STR Terdaftar' : 'SIPP Terdaftar', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF1E40AF), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Row( + children: [ + const Icon(Icons.star, size: 14, color: Color(0xFFF59E0B)), + const SizedBox(width: 3), + Text( + '${psychologist.rating.toStringAsFixed(1)} (${psychologist.reviewCount})', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + // Specialties Tags (Chips) + Wrap( + spacing: 6, + runSpacing: 4, + children: psychologist.specialties.take(3).map((specialty) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '#$specialty', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.primary, + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 12), + + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 12), + + // Pricing & Booking Action + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Biaya Sesi (${psychologist.durationMinutes} mnt)', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 1), + Text( + _formatCurrency(psychologist.feePerSession), + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + ElevatedButton( + onPressed: onBookTap, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), + child: Text( + 'Jadwalkan', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/chat/data/datasources/chat_datasource.dart b/apps/eva_mobile/lib/features/chat/data/datasources/chat_datasource.dart new file mode 100644 index 0000000..baafa15 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/datasources/chat_datasource.dart @@ -0,0 +1,9 @@ +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); + Stream streamConversationSummary(String conversationId); +} diff --git a/apps/eva_mobile/lib/features/chat/data/datasources/mock_chat_datasource.dart b/apps/eva_mobile/lib/features/chat/data/datasources/mock_chat_datasource.dart new file mode 100644 index 0000000..2c269e8 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/datasources/mock_chat_datasource.dart @@ -0,0 +1,124 @@ +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; + } + + @override + Stream streamConversationSummary(String conversationId) async* { + await Future.delayed(const Duration(milliseconds: 300)); + const sample = 'Sesi percakapan curhat bersama EVA berjalan dengan hangat dan memberikan ruang aman untuk refleksi batin.'; + for (final word in sample.split(' ')) { + await Future.delayed(const Duration(milliseconds: 60)); + yield '$word '; + } + } +} diff --git a/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart b/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart new file mode 100644 index 0000000..a1aa2fc --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'chat_datasource.dart'; +import '../models/conversation_model.dart'; +import '../models/chat_message_model.dart'; +import '../../../../core/config/app_config.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); + } + + @override + Stream streamConversationSummary(String conversationId) async* { + final headers = await AppConfig.getAuthHeaders(); + final url = Uri.parse('${AppConfig.baseUrl}/conversations/$conversationId/summary/stream'); + final request = http.Request('GET', url); + request.headers.addAll(headers); + + final client = http.Client(); + try { + final streamedResponse = await client.send(request).timeout(const Duration(seconds: 25)); + if (streamedResponse.statusCode != 200) { + throw ApiException('Failed to stream summary', statusCode: streamedResponse.statusCode); + } + + final stream = streamedResponse.stream + .transform(utf8.decoder) + .transform(const LineSplitter()); + + await for (final line in stream) { + final trimmed = line.trim(); + if (trimmed.startsWith('data:')) { + final jsonStr = trimmed.substring(5).trim(); + if (jsonStr.isEmpty) continue; + try { + final payload = jsonDecode(jsonStr); + if (payload is Map && payload.containsKey('token')) { + yield payload['token']?.toString() ?? ''; + } + } catch (_) {} + } + } + } finally { + client.close(); + } + } +} diff --git a/apps/eva_mobile/lib/features/chat/data/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..5dbf552 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/repositories/chat_repository_impl.dart @@ -0,0 +1,23 @@ +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); + + @override + Stream streamConversationSummary(String conversationId) => + _dataSource.streamConversationSummary(conversationId); +} diff --git a/apps/eva_mobile/lib/features/chat/data/repositories/fallback_chat_repository.dart b/apps/eva_mobile/lib/features/chat/data/repositories/fallback_chat_repository.dart new file mode 100644 index 0000000..4aef710 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/data/repositories/fallback_chat_repository.dart @@ -0,0 +1,51 @@ +import '../../domain/repositories/chat_repository.dart'; +import '../../domain/entities/conversation_entity.dart'; +import '../../domain/entities/chat_message_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackChatRepository implements ChatRepository { + final ChatRepository _remote; + final ChatRepository _mock; + + FallbackChatRepository({ + required ChatRepository remote, + required ChatRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future> getConversations() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getConversations(), + mockCall: () => _mock.getConversations(), + operationName: 'getConversations', + ); + } + + @override + Future getConversationById(String id) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getConversationById(id), + mockCall: () => _mock.getConversationById(id), + operationName: 'getConversationById($id)', + ); + } + + @override + Future sendMessage(String conversationId, String text) { + return FallbackExecutor.execute( + remoteCall: () => _remote.sendMessage(conversationId, text), + mockCall: () => _mock.sendMessage(conversationId, text), + operationName: 'sendMessage', + ); + } + + @override + Stream streamConversationSummary(String conversationId) { + return FallbackExecutor.executeStream( + remoteStreamCall: () => _remote.streamConversationSummary(conversationId), + mockStreamCall: () => _mock.streamConversationSummary(conversationId), + operationName: 'streamConversationSummary($conversationId)', + ); + } +} diff --git a/apps/eva_mobile/lib/features/chat/domain/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..729c125 --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/domain/repositories/chat_repository.dart @@ -0,0 +1,9 @@ +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); + Stream streamConversationSummary(String conversationId); +} diff --git a/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart b/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart new file mode 100644 index 0000000..44bf6bf --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart @@ -0,0 +1,31 @@ +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'; +import '../../data/repositories/fallback_chat_repository.dart'; + +final chatRepositoryProvider = Provider((ref) { + final mockRepo = ChatRepositoryImpl(MockChatDataSource()); + final remoteRepo = ChatRepositoryImpl(RemoteChatDataSource()); + + if (AppConfig.useMockData) { + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackChatRepository(remote: remoteRepo, mock: mockRepo); + } else { + return remoteRepo; + } +}); + +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/chat/presentation/screens/ai_conversation_screen.dart b/apps/eva_mobile/lib/features/chat/presentation/screens/ai_conversation_screen.dart new file mode 100644 index 0000000..6638c8c --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/presentation/screens/ai_conversation_screen.dart @@ -0,0 +1,587 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/chat/presentation/widgets/animated_session_card.dart'; +import 'package:eva_mobile/features/chat/presentation/widgets/session_list_skeleton.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; +import '../providers/chat_provider.dart'; + +class AiConversationScreen extends ConsumerStatefulWidget { + const AiConversationScreen({super.key}); + + @override + ConsumerState createState() => _AiConversationScreenState(); +} + +class _AiConversationScreenState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + List> _conversations = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _fetchConversations(isRefresh: true); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future _fetchConversations({bool isRefresh = false}) async { + if (isRefresh) { + setState(() => _isLoading = true); + } + + try { + final entities = await ref.read(chatRepositoryProvider).getConversations(); + final items = entities.map((c) => { + 'id': c.id, + 'title': c.title, + 'date': c.date, + 'lastMessageTime': c.lastSessionTime, + 'dominant_emoji': c.moodEmoji, + 'dominant_emotion': c.moodTag, + 'summary': c.summary, + 'messages': c.messages.map((m) => { + 'id': m.id, + 'sender': m.isUser ? 'user' : 'ai', + 'text': m.text, + 'time': m.time, + 'emotionEmoji': m.emotionEmoji, + }).toList(), + }).toList(); + + if (mounted) { + setState(() => _conversations = items); + } + } catch (_) {} finally { + if (mounted) setState(() => _isLoading = false); + } + } + + static ({String tag, String emoji}) _detectEmotionFallback(String text) { + final tLow = text.toLowerCase(); + const anxWords = ['cemas', 'takut', 'nervous', 'panggung', 'khawatir', 'panik', 'deg-degan', 'gugup', 'takutnya', 'bingung', 'was-was', 'tegang']; + const depWords = ['sedih', 'nangis', 'menangis', 'hampa', 'sendiri', 'kehilangan', 'terpuruk', 'putus asa', 'kecewa', 'patah hati', 'bunuh diri']; + const strWords = ['stres', 'capek', 'lelah', 'beban', 'berat', 'penat', 'pusing', 'mumet', 'tekanan', 'tugas', 'deadline', 'kerjaan', 'letih']; + const angWords = ['marah', 'kesal', 'jengkel', 'benci', 'emosi', 'sebal', 'kesel', 'geram', 'murka']; + const posWords = ['lega', 'senang', 'bahagia', 'terima kasih', 'makasih', 'enakan', 'tenang', 'nyaman', 'santai', 'alhamdulillah', 'syukurlah', 'baik', 'bersyukur']; + + if (anxWords.any((w) => tLow.contains(w))) { + return (tag: 'fear (78%)', emoji: '😨'); + } + if (depWords.any((w) => tLow.contains(w))) { + return (tag: 'sadness (82%)', emoji: '😔'); + } + if (strWords.any((w) => tLow.contains(w))) { + return (tag: 'stress (75%)', emoji: '💥'); + } + if (angWords.any((w) => tLow.contains(w))) { + return (tag: 'anger (70%)', emoji: '😡'); + } + if (posWords.any((w) => tLow.contains(w))) { + return (tag: 'calm (85%)', emoji: '😌'); + } + return (tag: 'netral (60%)', emoji: '😐'); + } + + @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: [ + + const AppBackButton(), + const SizedBox(width: 10), + + 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]), + ), + ), + ], + ) + : 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, + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/chat/presentation/widgets/animated_session_card.dart b/apps/eva_mobile/lib/features/chat/presentation/widgets/animated_session_card.dart new file mode 100644 index 0000000..0ce1cca --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/presentation/widgets/animated_session_card.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +class AnimatedSessionCard extends StatefulWidget { + final int index; + final Widget child; + + const AnimatedSessionCard({ + super.key, + required this.index, + required this.child, + }); + + @override + State createState() => _AnimatedSessionCardState(); +} + +class _AnimatedSessionCardState extends State + 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/features/chat/presentation/widgets/session_list_skeleton.dart b/apps/eva_mobile/lib/features/chat/presentation/widgets/session_list_skeleton.dart new file mode 100644 index 0000000..acdc8eb --- /dev/null +++ b/apps/eva_mobile/lib/features/chat/presentation/widgets/session_list_skeleton.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +class SessionListSkeleton extends StatefulWidget { + const SessionListSkeleton({super.key}); + + @override + State createState() => _SessionListSkeletonState(); +} + +class _SessionListSkeletonState extends State + 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, + ), + ), + ], + ), + ), + ); + }), + ); + }, + ); + } +} 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/data/repositories/fallback_dass_repository.dart b/apps/eva_mobile/lib/features/dass/data/repositories/fallback_dass_repository.dart new file mode 100644 index 0000000..9a3330b --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/data/repositories/fallback_dass_repository.dart @@ -0,0 +1,41 @@ +import '../../domain/repositories/dass_repository.dart'; +import '../../domain/entities/dass_assessment_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackDASSRepository implements DASSRepository { + final DASSRepository _remote; + final DASSRepository _mock; + + FallbackDASSRepository({ + required DASSRepository remote, + required DASSRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future getTodayAssessment() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getTodayAssessment(), + mockCall: () => _mock.getTodayAssessment(), + operationName: 'getTodayAssessment', + ); + } + + @override + Future updateAssessment(List> items) { + return FallbackExecutor.execute( + remoteCall: () => _remote.updateAssessment(items), + mockCall: () => _mock.updateAssessment(items), + operationName: 'updateAssessment', + ); + } + + @override + Future extractTodayAssessment() { + return FallbackExecutor.execute( + remoteCall: () => _remote.extractTodayAssessment(), + mockCall: () => _mock.extractTodayAssessment(), + operationName: '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..346f7bb --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/providers/dass_provider.dart @@ -0,0 +1,146 @@ +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 '../../data/repositories/fallback_dass_repository.dart'; +import '../../domain/entities/dass_assessment_entity.dart'; +import '../../domain/repositories/dass_repository.dart'; +import '../../../monitoring/presentation/providers/monitoring_provider.dart'; + +final dassRepositoryProvider = Provider((ref) { + final mockRepo = DASSRepositoryImpl(MockDASSDataSource()); + final remoteRepo = DASSRepositoryImpl(RemoteDASSDataSource()); + + if (AppConfig.useMockData) { + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackDASSRepository(remote: remoteRepo, mock: mockRepo); + } else { + return remoteRepo; + } +}); + +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/screens/dass_assessment_screen.dart b/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart new file mode 100644 index 0000000..fea4bc4 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart @@ -0,0 +1,1641 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:eva_mobile/core/utils/responsive_layout_helper.dart'; +import 'package:eva_mobile/features/dass/domain/entities/dass_assessment_entity.dart'; +import 'package:eva_mobile/features/dass/presentation/providers/dass_provider.dart'; +import 'package:eva_mobile/features/dass/presentation/widgets/bouncing_button.dart'; +import 'package:eva_mobile/features/dass/presentation/widgets/dass_ai_analysis_accordion.dart'; +import 'package:eva_mobile/features/dass/presentation/widgets/dass_skeleton_loader.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class 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) { + AppSnackBar.showWithMessenger( + scaffoldMessenger, + message: + 'Asesmen DASS-21 hari ini berhasil diverifikasi dan disimpan!', + variant: SnackBarVariant.success, + ); + } + }, + 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: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Lembar Evaluasi DASS-21', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: const Color(0xFF1E1B4B), + ), + ), + Text( + 'Depression Anxiety Stress Scale (21 Butir)', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: const Color(0xFF6B7280), + ), + ), + ], + ), + ), + IconButton( + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + const Icon(Icons.auto_awesome, color: Color(0xFF7C3AED), size: 24), + const SizedBox(width: 8), + Text( + 'Konfirmasi Ekstraksi AI', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w700, + color: const Color(0xFF1E1B4B), + ), + ), + ], + ), + content: Text( + 'Apakah Anda yakin ingin memicu analisis AI untuk mengekstrak 21 butir DASS-21 dari riwayat percakapan hari ini?\n\n' + 'Proses ini akan menganalisis transkrip percakapan terbaru untuk mendeteksi indikator emosi Anda.', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + color: const Color(0xFF4B5563), + height: 1.45, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogCtx, false), + child: Text( + 'Batal', + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w600, + color: const Color(0xFF6B7280), + ), + ), + ), + ElevatedButton.icon( + onPressed: () => Navigator.pop(dialogCtx, true), + icon: const Icon(Icons.auto_awesome, size: 16), + label: Text( + 'Ekstrak Sekarang', + style: GoogleFonts.plusJakartaSans(fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF7C3AED), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ], + ), + ); + + if (confirm != true || !mounted) return; + + AppSnackBar.showPrimary( + this.context, + 'Menganalisis dan mengekstrak DASS-21 dari riwayat dialog hari ini...', + duration: const Duration(seconds: 4), + leading: const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + ); + await notifier.extractTodayAssessment(); + }, + icon: const Icon(Icons.auto_awesome, size: 20, color: Color(0xFF7C3AED)), + tooltip: 'Ekstrak AI dari Percakapan Hari Ini', + ), + ], + ), + ); + } + + Widget _buildPeriodSelector() { + final periods = [ + {'key': 'today', 'label': 'Hari Ini'}, + {'key': 'week', 'label': 'Minggu Ini'}, + {'key': 'month', 'label': 'Bulan Ini'}, + ]; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFE5E7EB).withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: periods.map((p) { + final isSelected = p['key'] == _currentPeriod; + return Expanded( + child: GestureDetector( + onTap: () { + setState(() { + _currentPeriod = p['key']!; + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isSelected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Center( + child: Text( + p['label']!, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? const Color(0xFF1E1B4B) : const Color(0xFF6B7280), + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildModeBanner(bool isEditable) { + if (isEditable) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 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 DassAIAnalysisAccordion( + isExpanded: isExpanded, + item: item, + confBg: confBg, + confColor: confColor, + confPercent: confPercent, + confLabel: confLabel, + hasEvidence: hasEvidence, + hasReason: hasReason, + ); + } + + Widget _buildLikertButton( + int score, + String label, + bool isSelected, + 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) { + AppSnackBar.showError( + context, + 'Tidak dapat membuka WhatsApp Konseling Krisis', + ); + } + } + } + } + + +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/bouncing_button.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/bouncing_button.dart new file mode 100644 index 0000000..2e63407 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/bouncing_button.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +class BouncingButton extends StatefulWidget { + final Widget child; + final VoidCallback? onTap; + final double scaleFactor; + + const BouncingButton({ + super.key, + required this.child, + this.onTap, + this.scaleFactor = 0.94, + }); + + @override + State 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, + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_ai_analysis_accordion.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_ai_analysis_accordion.dart new file mode 100644 index 0000000..43773d1 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_ai_analysis_accordion.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../domain/entities/dass_assessment_entity.dart'; + +class DassAIAnalysisAccordion extends StatefulWidget { + final bool isExpanded; + final DASSItemEntity item; + final Color confBg; + final Color confColor; + final int confPercent; + final String confLabel; + final bool hasEvidence; + final bool hasReason; + + const DassAIAnalysisAccordion({ + super.key, + required this.isExpanded, + required this.item, + required this.confBg, + required this.confColor, + required this.confPercent, + required this.confLabel, + required this.hasEvidence, + required this.hasReason, + }); + + @override + State createState() => _DassAIAnalysisAccordionState(); +} + +class _DassAIAnalysisAccordionState extends State + 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 DassAIAnalysisAccordion oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isExpanded != oldWidget.isExpanded) { + if (widget.isExpanded) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + if (_controller.isDismissed && !widget.isExpanded) { + return const SizedBox.shrink(); + } + return ClipRect( + child: SizeTransition( + sizeFactor: _expandAnimation, + alignment: Alignment.topCenter, + child: FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: child, + ), + ), + ), + ); + }, + child: Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Confidence Meter Bar + Row( + children: [ + Text( + 'Tingkat Keyakinan AI', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: const Color(0xFF334155), + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: widget.confBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: widget.confColor.withValues(alpha: 0.3)), + ), + child: Text( + '${widget.confPercent}% • ${widget.confLabel}', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: widget.confColor, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Container( + height: 5, + width: double.infinity, + color: const Color(0xFFE2E8F0), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: widget.item.confidence.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: widget.confColor, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + + // 2. AI Evidence Quote (if present) + if (widget.hasEvidence) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.format_quote_rounded, size: 16, color: Color(0xFF6366F1)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Bukti Obrolan Pengguna:', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF64748B), + ), + ), + const SizedBox(height: 2), + Text( + '"${widget.item.evidence}"', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontStyle: FontStyle.italic, + color: const Color(0xFF1E293B), + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ], + + // 3. AI Rationale / Reason (if present) + if (widget.hasReason) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFDDD6FE)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.psychology_alt_rounded, size: 16, color: Color(0xFF7C3AED)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Penalaran AI Eva:', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF6D28D9), + ), + ), + const SizedBox(height: 2), + Text( + widget.item.reason!, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: const Color(0xFF334155), + height: 1.35, + ), + ), + ], + ), + ), + ], + ), + ), + ] else if (!widget.hasEvidence) ...[ + const SizedBox(height: 8), + Text( + 'Tidak ditemukan indikasi keluhan pada transkrip obrolan hari ini (Skor 0).', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontStyle: FontStyle.italic, + color: const Color(0xFF64748B), + ), + ), + ], + + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.info_outline_rounded, size: 12, color: Color(0xFF94A3B8)), + const SizedBox(width: 4), + Expanded( + child: Text( + 'Pilihan ini dianalisis otomatis. Kamu dapat mengoreksi skor kapan saja di bawah.', + style: GoogleFonts.plusJakartaSans( + fontSize: 9.5, + color: const Color(0xFF94A3B8), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_form_card.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_form_card.dart new file mode 100644 index 0000000..9a8a288 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_form_card.dart @@ -0,0 +1,466 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_snack_bar.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) { + AppSnackBar.showWithMessenger( + scaffoldMessenger, + message: + 'Koreksi DASS-21 berhasil disimpan & ritem emosional diselaraskan!', + variant: SnackBarVariant.success, + ); + } + }, + 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/dass/presentation/widgets/dass_skeleton_loader.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_skeleton_loader.dart new file mode 100644 index 0000000..5a19b72 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_skeleton_loader.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import '../../../../widgets/glass_card.dart'; +import '../../../../widgets/skeleton_shimmer.dart'; + +/// Shimmer skeleton loader for the DASS-21 Assessment Screen. +class DassSkeletonLoader extends StatelessWidget { + const DassSkeletonLoader({super.key}); + + @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/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..4fd46ba --- /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..7e11eaf --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/data/repositories/diary_repository_impl.dart @@ -0,0 +1,29 @@ +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 getTodayDiary() => _dataSource.getTodayDiary(); + + @override + Future generateTodayDiary() => _dataSource.generateTodayDiary(); + + @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/data/repositories/fallback_diary_repository.dart b/apps/eva_mobile/lib/features/diary/data/repositories/fallback_diary_repository.dart new file mode 100644 index 0000000..b889852 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/data/repositories/fallback_diary_repository.dart @@ -0,0 +1,68 @@ +import '../../domain/repositories/diary_repository.dart'; +import '../../domain/entities/diary_entry_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackDiaryRepository implements DiaryRepository { + final DiaryRepository _remote; + final DiaryRepository _mock; + + FallbackDiaryRepository({ + required DiaryRepository remote, + required DiaryRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future> getDiaryEntries({String? moodFilter, String? searchQuery}) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getDiaryEntries(moodFilter: moodFilter, searchQuery: searchQuery), + mockCall: () => _mock.getDiaryEntries(moodFilter: moodFilter, searchQuery: searchQuery), + operationName: 'getDiaryEntries', + ); + } + + @override + Future getDiaryEntryById(String id) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getDiaryEntryById(id), + mockCall: () => _mock.getDiaryEntryById(id), + operationName: 'getDiaryEntryById($id)', + ); + } + + @override + Future getTodayDiary() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getTodayDiary(), + mockCall: () => _mock.getTodayDiary(), + operationName: 'getTodayDiary', + ); + } + + @override + Future generateTodayDiary() { + return FallbackExecutor.execute( + remoteCall: () => _remote.generateTodayDiary(), + mockCall: () => _mock.generateTodayDiary(), + operationName: 'generateTodayDiary', + ); + } + + @override + Future createDiaryEntry(String content, String moodTag) { + return FallbackExecutor.execute( + remoteCall: () => _remote.createDiaryEntry(content, moodTag), + mockCall: () => _mock.createDiaryEntry(content, moodTag), + operationName: 'createDiaryEntry', + ); + } + + @override + Future deleteDiaryEntry(String id) { + return FallbackExecutor.execute( + remoteCall: () => _remote.deleteDiaryEntry(id), + mockCall: () => _mock.deleteDiaryEntry(id), + operationName: 'deleteDiaryEntry($id)', + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/domain/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..75ed272 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/domain/repositories/diary_repository.dart @@ -0,0 +1,10 @@ +import '../entities/diary_entry_entity.dart'; + +abstract class DiaryRepository { + 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/presentation/providers/diary_provider.dart b/apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart new file mode 100644 index 0000000..6425438 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart @@ -0,0 +1,36 @@ +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'; +import '../../data/repositories/fallback_diary_repository.dart'; + +final diaryRepositoryProvider = Provider((ref) { + final mockRepo = DiaryRepositoryImpl(MockDiaryDataSource()); + final remoteRepo = DiaryRepositoryImpl(RemoteDiaryDataSource()); + + if (AppConfig.useMockData) { + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackDiaryRepository(remote: remoteRepo, mock: mockRepo); + } else { + return remoteRepo; + } +}); + +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/diary/presentation/screens/ai_diary_detail_screen.dart b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_detail_screen.dart new file mode 100644 index 0000000..73ed017 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_detail_screen.dart @@ -0,0 +1,1647 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:eva_mobile/features/chat/presentation/providers/chat_provider.dart'; +import 'package:eva_mobile/features/diary/data/models/diary_entry_model.dart'; +import 'package:eva_mobile/features/diary/presentation/providers/diary_provider.dart'; +import 'package:eva_mobile/features/diary/presentation/widgets/summary_skeleton_loader.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/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 ConsumerStatefulWidget { + final Map? journalData; + + const AiDiaryDetailScreen({super.key, this.journalData}); + + @override + ConsumerState createState() => _AiDiaryDetailScreenState(); +} + +class _AiDiaryDetailScreenState extends ConsumerState { + 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; + + @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 (_isStreaming) return; + + setState(() { + _isLoadingSkeleton = true; + _isStreaming = true; + _streamError = false; + _streamedSummary = null; + }); + + try { + final stream = ref.read(chatRepositoryProvider).streamConversationSummary(conversationId); + await for (final token in stream) { + if (!mounted) break; + setState(() { + _isLoadingSkeleton = false; + _streamedSummary = (_streamedSummary ?? '') + token; + }); + } + if (mounted) { + setState(() { + _isStreaming = false; + _isLoadingSkeleton = false; + if (_diaryData != null && _streamedSummary != null) { + _diaryData!['summary'] = _streamedSummary; + } + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isStreaming = false; + _isLoadingSkeleton = false; + _streamError = true; + }); + } + } finally { + if (mounted && _isStreaming) { + setState(() { + _isStreaming = false; + _isLoadingSkeleton = false; + }); + } + } + } + + Future _fetchDiaryDetail(String diaryId) async { + try { + final repository = ref.read(diaryRepositoryProvider); + final entry = await repository.getDiaryEntryById(diaryId); + if (entry != null && mounted) { + setState(() { + if (entry is DiaryEntryModel) { + _diaryData = entry.toJson(); + } else { + _diaryData = { + 'id': entry.id, + 'title': entry.title, + 'date': entry.date, + 'sessionCount': entry.sessionCount, + 'lastSessionTime': entry.lastSessionTime, + 'moodTag': entry.moodTag, + 'moodEmoji': entry.moodEmoji, + 'summary': entry.summary, + 'aiInsight': entry.aiInsight, + 'importantEvents': entry.importantEvents, + 'emotionalReflection': entry.emotionalReflection, + 'sessions': entry.sessions.map((s) => { + 'id': s.id, + 'title': s.title, + 'time': s.time, + 'moodTag': s.moodTag, + 'moodEmoji': s.moodEmoji, + }).toList(), + }; + } + }); + } + } catch (_) { + // 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: [ + + const AppBackButton(), + const SizedBox(width: 12), + + 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: 46, + child: ListView( + clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(vertical: 3), + scrollDirection: Axis.horizontal, + children: [ + ChoiceChip( + showCheckmark: false, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + label: Text('Semua Sesi (${sessions.length})'), + selected: _selectedSessionId == 'all', + selectedColor: AppColors.primaryContainer, + backgroundColor: Colors.white.withValues(alpha: 0.9), + labelStyle: GoogleFonts.inter( + fontSize: 12, + fontWeight: _selectedSessionId == 'all' ? FontWeight.w700 : FontWeight.w500, + color: _selectedSessionId == 'all' ? AppColors.primary : AppColors.textSecondary, + ), + 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, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + 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), + + ], + + ), + + ), + + ), + + ], + + ), + + ), + + ), + + ); + + } + +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_screen.dart b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_screen.dart new file mode 100644 index 0000000..6c29220 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_screen.dart @@ -0,0 +1,726 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/diary/data/models/diary_entry_model.dart'; +import 'package:eva_mobile/features/diary/presentation/providers/diary_provider.dart'; +import 'package:eva_mobile/services/daily_progress_local_service.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/skeleton_shimmer.dart'; + +class AiDiaryScreen extends ConsumerStatefulWidget { + const AiDiaryScreen({super.key}); + + @override + ConsumerState createState() => AiDiaryScreenState(); +} + +class AiDiaryScreenState extends ConsumerState { + + 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 (mounted) setState(() => _isLoading = true); + try { + final repository = ref.read(diaryRepositoryProvider); + final entries = await repository.getDiaryEntries(); + if (mounted) { + setState(() { + _entries = entries.map((e) { + if (e is DiaryEntryModel) return e.toJson(); + return { + 'id': e.id, + 'title': e.title, + 'date': e.date, + 'sessionCount': e.sessionCount, + 'lastSessionTime': e.lastSessionTime, + 'moodTag': e.moodTag, + 'moodEmoji': e.moodEmoji, + 'summary': e.summary, + 'aiInsight': e.aiInsight, + 'importantEvents': e.importantEvents, + 'emotionalReflection': e.emotionalReflection, + 'sessions': e.sessions.map((s) => { + 'id': s.id, + 'title': s.title, + 'time': s.time, + 'moodTag': s.moodTag, + 'moodEmoji': s.moodEmoji, + }).toList(), + }; + }).toList(); + }); + } + final todayStr = DailyProgressLocalService.getTodayWibDate(); + final hasTodayEntry = _entries.any((e) { + final date = e['date']?.toString() ?? e['created_at']?.toString() ?? ''; + return date.startsWith(todayStr); + }); + if (hasTodayEntry) { + DailyProgressLocalService.recordDiaryCheckin(hasDiary: true); + } + } catch (_) { + // Keep existing entries or empty + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final query = _searchController.text.toLowerCase(); + final filteredList = _entries.where((item) { + final matchesQuery = query.isEmpty || + item['title'].toString().toLowerCase().contains(query) || + item['summary'].toString().toLowerCase().contains(query) || + item['moodTag'].toString().toLowerCase().contains(query); + + + + final matchesFilter = _selectedFilter == 'Semua' || + + item['moodTag'].toString().toLowerCase().contains(_selectedFilter.split(' ').first.toLowerCase()); + + + + return matchesQuery && matchesFilter; + + }).toList(); + + + + return Scaffold( + + body: Container( + + width: double.infinity, + + height: double.infinity, + + decoration: const BoxDecoration( + + gradient: LinearGradient( + + colors: [ + + Color(0xFFF6F8FF), + + Color(0xFFEFF2FE), + + Color(0xFFF8F9FE), + + ], + + begin: Alignment.topCenter, + + end: Alignment.bottomCenter, + + ), + + ), + + child: SafeArea( + + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: _fetchRemoteDiaries, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + + padding: const EdgeInsets.fromLTRB(20.0, 16.0, 20.0, 100.0), + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + // Title Header + + Text( + + 'Jurnal AI Harian & Sesi', + + style: GoogleFonts.inter( + + fontSize: 24, + + fontWeight: FontWeight.w800, + + color: AppColors.textPrimary, + + ), + + ), + + const SizedBox(height: 4), + + Text( + 'Rangkuman refleksi emosi dan transkrip suara.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + + const SizedBox(height: 20), + + + + // Search Bar Field + + Container( + + decoration: BoxDecoration( + + color: Colors.white.withValues(alpha: 0.9), + + borderRadius: BorderRadius.circular(999), + + boxShadow: [ + + BoxShadow( + + color: Colors.black.withValues(alpha: 0.04), + + blurRadius: 16, + + offset: const Offset(0, 4), + + ), + + ], + + ), + + child: TextField( + + controller: _searchController, + + onChanged: (_) => setState(() {}), + + style: GoogleFonts.inter(fontSize: 14, color: AppColors.textPrimary), + + decoration: InputDecoration( + + hintText: 'Cari jurnal atau kata kunci emosi...', + + hintStyle: GoogleFonts.inter(fontSize: 13, color: AppColors.textLight), + + prefixIcon: const Icon(Icons.search, color: AppColors.primary, size: 20), + + suffixIcon: query.isNotEmpty + + ? IconButton( + + icon: const Icon(Icons.clear, size: 18), + + onPressed: () { + + _searchController.clear(); + + setState(() {}); + + }, + + ) + + : null, + + border: InputBorder.none, + + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + + ), + + ), + + ), + + const SizedBox(height: 16), + + + + // Filter Category Chips + SizedBox( + height: 46, + child: ListView.separated( + clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(vertical: 3), + scrollDirection: Axis.horizontal, + itemCount: _filterCategories.length, + separatorBuilder: (context, index) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final category = _filterCategories[index]; + final isSelected = category == _selectedFilter; + return ChoiceChip( + showCheckmark: false, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + label: Text(category), + + selected: isSelected, + + selectedColor: AppColors.primaryContainer, + + backgroundColor: Colors.white.withValues(alpha: 0.8), + + labelStyle: GoogleFonts.inter( + + fontSize: 12, + + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + + color: isSelected ? AppColors.primary : AppColors.textSecondary, + + ), + + shape: RoundedRectangleBorder( + + borderRadius: BorderRadius.circular(999), + + side: BorderSide( + + color: isSelected ? AppColors.primary : Colors.transparent, + + ), + + ), + + onSelected: (selected) { + + setState(() { + + _selectedFilter = category; + + }); + + }, + + ); + + }, + + ), + + ), + + const SizedBox(height: 20), + + // Journal History Cards List / Skeleton Loader + if (_isLoading && _entries.isEmpty) + 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/features/diary/presentation/widgets/summary_skeleton_loader.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/summary_skeleton_loader.dart new file mode 100644 index 0000000..c4140be --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/summary_skeleton_loader.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme/app_colors.dart'; + +/// Shimmer skeleton loader for AI Diary summary content. +class SummarySkeletonLoader extends StatefulWidget { + const SummarySkeletonLoader({super.key}); + + @override + State createState() => _SummarySkeletonLoaderState(); +} + +class _SummarySkeletonLoaderState extends State + 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/features/emergency/presentation/screens/emergency_contacts_screen.dart b/apps/eva_mobile/lib/features/emergency/presentation/screens/emergency_contacts_screen.dart new file mode 100644 index 0000000..f365b85 --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/screens/emergency_contacts_screen.dart @@ -0,0 +1,707 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/auth/domain/entities/emergency_contact_entity.dart'; +import 'package:eva_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:eva_mobile/features/emergency/presentation/widgets/contact_form_dialog.dart'; +import 'package:eva_mobile/features/emergency/presentation/widgets/emergency_contacts_skeleton_loader.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class EmergencyContactsScreen extends ConsumerStatefulWidget { + const EmergencyContactsScreen({super.key}); + + @override + ConsumerState createState() => _EmergencyContactsScreenState(); +} + +class _EmergencyContactsScreenState extends ConsumerState { + List> _contacts = []; + bool _isLoading = false; + + void _copyToClipboard(String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + AppSnackBar.showSuccess( + context, + '$label ($text) berhasil disalin ke clipboard', + duration: const Duration(seconds: 2), + ); + } + + @override + void initState() { + super.initState(); + _loadContacts(); + } + + Future _loadContacts() async { + setState(() => _isLoading = true); + try { + final contacts = await ref.read(authRepositoryProvider).getEmergencyContacts(); + if (mounted) { + setState(() { + _contacts = contacts.map>((c) => { + 'id': c.id, + 'name': c.name, + 'relation': c.relationship.isEmpty ? 'Kontak Darurat' : c.relationship, + 'phone': c.phone, + 'isPrimary': c.isPrimary, + }).toList(); + }); + } + } catch (_) {} + finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _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 { + await ref.read(authRepositoryProvider).addEmergencyContact( + EmergencyContactEntity( + id: contactToEdit != null ? (contactToEdit['id']?.toString() ?? '') : '', + name: name, + relationship: relation.isEmpty ? 'Keluarga' : relation, + phone: phone, + isPrimary: isPrimary, + ), + ); + _loadContacts(); + } catch (_) {} + } + + Future _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.isNotEmpty) { + try { + await ref.read(authRepositoryProvider).deleteEmergencyContact(contactId); + } catch (_) {} + } + } + + + + void _showContactFormDialog({Map? contactToEdit}) { + ContactFormDialog.show( + context: context, + contactToEdit: contactToEdit, + onSave: ({ + required String name, + required String relation, + required String phone, + required bool isPrimary, + Map? contactToEdit, + }) { + _saveContactToBackend( + name: name, + relation: relation, + phone: phone, + isPrimary: isPrimary, + contactToEdit: contactToEdit, + ); + }, + ); + } + + 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); + AppSnackBar.showWarning( + context, + 'Kontak "${contact['name']}" telah dihapus.', + ); + }, + + child: Text( + + 'Hapus', + + style: GoogleFonts.inter(color: Colors.white, fontWeight: FontWeight.w600), + + ), + + ), + + ], + + ); + + }, + + ); + + } + + + + @override + + Widget build(BuildContext context) { + + return Scaffold( + + body: Container( + + width: double.infinity, + + height: double.infinity, + + decoration: const BoxDecoration( + + gradient: LinearGradient( + + colors: [ + + Color(0xFFF6F8FF), + + Color(0xFFEFF2FE), + + Color(0xFFF8F9FE), + + ], + + begin: Alignment.topCenter, + + end: Alignment.bottomCenter, + + ), + + ), + + child: SafeArea( + + child: Column( + + children: [ + + // Header Bar with Back Button Only (Cleaned App Bar) + StaggeredEntrance( + index: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Text( + 'Kontak Darurat Krisis', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ), + + const Divider(height: 1, color: Color(0xFFEBECEF)), + + + + // Scrollable Content + + Expanded( + + child: SingleChildScrollView( + + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), + + child: Column( + + crossAxisAlignment: CrossAxisAlignment.start, + + children: [ + + // Info Banner Card + StaggeredEntrance( + index: 1, + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(18), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFFFDCDD), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.shield_outlined, + color: Color(0xFFD32F2F), + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'RENCANA KESELAMATAN KRISIS', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 4), + Text( + 'Kontak ini siap dihubungi secara instan saat sistem EVA mendeteksi indikasi krisis emosional tinggi.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + + // List Header Title (Cleaned header without duplicate button) + StaggeredEntrance( + index: 2, + child: Text( + 'DAFTAR KONTAK (${_contacts.length})', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + ), + const SizedBox(height: 12), + + + + // Contacts List Items + StaggeredEntrance( + index: 3, + child: _isLoading + ? const EmergencyContactsSkeletonLoader() + : _contacts.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 40.0), + child: Column( + children: [ + const Icon(Icons.contacts_outlined, size: 48, color: AppColors.textLight), + const SizedBox(height: 12), + Text( + 'Belum ada kontak darurat', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ) + : ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _contacts.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final contact = _contacts[index]; + final bool isPrimary = contact['isPrimary'] == true; + return GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: isPrimary ? const Color(0xFFFFDCDD) : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + isPrimary ? Icons.star_rounded : Icons.person_outline, + color: isPrimary ? const Color(0xFFD32F2F) : AppColors.primary, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + contact['name'] ?? '', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isPrimary) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFFFEBEE), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFFFCDD2)), + ), + child: Text( + 'UTAMA', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w800, + color: const Color(0xFFD32F2F), + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + contact['relationship'] ?? '', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + const Divider(height: 1, color: Color(0xFFF0F1F5)), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: InkWell( + onTap: () => _copyToClipboard(contact['phone'] ?? '', 'Nomor Telepon'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + const Icon(Icons.phone_outlined, size: 16, color: AppColors.textSecondary), + const SizedBox(width: 8), + Flexible( + child: Text( + contact['phone'] ?? '', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 10), + IconButton( + icon: const Icon(Icons.edit_outlined, size: 20), + color: AppColors.primary, + onPressed: () => _showContactFormDialog(contactToEdit: contact), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + color: const Color(0xFFD32F2F), + onPressed: () => _confirmDeleteContact(contact), + ), + ], + ), + ], + ), + ); + }, + ), + ), + + const SizedBox(height: 28), + + // Official Emergency & Counseling Hotlines Section + StaggeredEntrance( + index: 4, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFFE4DCFF), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.support_agent, + size: 16, + color: Color(0xFF6C5CE7), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Hotline Resmi 24 Jam', + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + letterSpacing: 0.3, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + _buildOfficialHotlineCard( + title: 'Layanan SEJIWA (Kemenkes & BNPB)', + desc: 'Konseling psikologis gratis dari pemerintah RI', + number: '119', + displayNumber: '119 (Ekstensi 8)', + icon: Icons.local_hospital_outlined, + accentColor: const Color(0xFF6C5CE7), + badgeBg: const Color(0xFFF3F0FF), + ), + const SizedBox(height: 10), + _buildOfficialHotlineCard( + title: 'LISA - Sahabat Jiwa', + desc: 'Hotline pencegahan krisis & WhatsApp 24 Jam', + number: '08113855472', + displayNumber: '0811-3855-472', + icon: Icons.phone_in_talk_outlined, + accentColor: const Color(0xFF00CEC9), + badgeBg: const Color(0xFFD7F3FF), + ), + const SizedBox(height: 10), + _buildOfficialHotlineCard( + title: 'Nomor Darurat Nasional Terpadu', + desc: 'Panggilan darurat nasional bebas pulsa (Polisi/Damkar/Medis)', + number: '112', + displayNumber: '112', + icon: Icons.emergency_outlined, + accentColor: const Color(0xFFD32F2F), + badgeBg: const Color(0xFFFFDCDD), + ), + const SizedBox(height: 10), + _buildOfficialHotlineCard( + title: 'Halo Kemenkes', + desc: 'Informasi fasilitas kesehatan & layanan rujukan', + number: '1500567', + displayNumber: '1500-567', + icon: Icons.info_outline, + accentColor: const Color(0xFF0984E3), + badgeBg: const Color(0xFFDFE6E9), + ), + ], + ), + ), + const SizedBox(height: 80), + + ], + + ), + + ), + + ), + + ], + + ), + + ), + + ), + // Single Efficient Floating Action Button (FAB) + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showContactFormDialog(), + backgroundColor: AppColors.primary, + icon: const Icon(Icons.add, color: Colors.white), + label: Text( + 'Tambah Kontak', + style: GoogleFonts.inter(color: Colors.white, fontWeight: FontWeight.w600), + ), + ), + ); + } + + Widget _buildOfficialHotlineCard({ + required String title, + required String desc, + required String number, + required String displayNumber, + required IconData icon, + required Color accentColor, + required Color badgeBg, + }) { + return GlassCard( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: badgeBg, + shape: BoxShape.circle, + ), + child: Icon(icon, color: accentColor, size: 20), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + desc, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + displayNumber, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w800, + color: accentColor, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 18, color: AppColors.textSecondary), + tooltip: 'Salin nomor', + onPressed: () => _copyToClipboard(number, title), + ), + ], + ), + ); + } +} + diff --git a/apps/eva_mobile/lib/features/emergency/presentation/screens/support_emergency_screen.dart b/apps/eva_mobile/lib/features/emergency/presentation/screens/support_emergency_screen.dart new file mode 100644 index 0000000..7293030 --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/screens/support_emergency_screen.dart @@ -0,0 +1,443 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:eva_mobile/core/routes/app_route_transitions.dart'; +import 'package:eva_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:eva_mobile/features/emergency/presentation/widgets/breathing_exercise_modal.dart'; +import 'package:eva_mobile/features/emergency/presentation/widgets/emergency_hotline_modal.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class SupportEmergencyScreen extends ConsumerStatefulWidget { + const SupportEmergencyScreen({super.key}); + + /// Custom Route with smooth fade & gentle slide-up animation for enter and exit + static Route route() { + return AppRouteTransitions.modalSlideUpRoute( + page: const SupportEmergencyScreen(), + ); + } + + @override + ConsumerState createState() => _SupportEmergencyScreenState(); +} + +class _SupportEmergencyScreenState extends ConsumerState + 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 contacts = await ref.read(authRepositoryProvider).getEmergencyContacts(); + if (contacts.isNotEmpty && mounted) { + final primary = contacts.firstWhere( + (c) => c.isPrimary, + orElse: () => contacts.first, + ); + setState(() { + _primaryContact = { + 'id': primary.id, + 'name': primary.name, + 'relationship': primary.relationship, + 'phone': primary.phone, + 'is_primary': primary.isPrimary, + }; + _isLoadingContact = false; + }); + return; + } + } catch (_) {} + if (mounted) { + setState(() { + _isLoadingContact = false; + }); + } + } + + Future _openDinkesHotline() async { + final uri = Uri.parse(_dinkesWhatsappUrl); + try { + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched) { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } + } catch (_) { + try { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } catch (_) { + if (mounted) { + AppSnackBar.showError(context, 'Tidak dapat membuka WhatsApp Dinkes'); + } + } + } + } + + void _onTapHubungiOrangTerdekat() { + if (_primaryContact != null && _primaryContact!['phone'] != null) { + final phone = _primaryContact!['phone'].toString().replaceAll(RegExp(r'[^\d+]'), ''); + final uri = Uri.parse('tel:$phone'); + launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + Navigator.pushNamed(context, '/emergency_contacts'); + } + } + + void _openBreathingExercise() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => const BreathingExerciseModal(), + ); + } + + void _openHotlineModal() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => const EmergencyHotlineModal(), + ); + } + + void _handleDismiss() { + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final contactSubtitle = _isLoadingContact + ? 'Memuat data kontak...' + : (_primaryContact != null + ? '${_primaryContact!['name']} (${_primaryContact!['relation']})' + : 'Atur nomor kontak keluarga atau teman'); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + _handleDismiss(); + } + }, + child: Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFFFF4F5), + Color(0xFFFFE8EC), + Color(0xFFFFF0F2), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 20.0), + child: Column( + children: [ + const Spacer(flex: 1), + + // Heart Badge Visual with Breathing Pulse Animation & Tap action // Animated Soft Emergency / Pulse Indicator + StaggeredEntrance( + index: 0, + child: Column( + children: [ + GestureDetector( + onTap: _openBreathingExercise, + child: AnimatedBuilder( + animation: _pulseScale, + builder: (context, child) { + return Transform.scale( + scale: _pulseScale.value, + child: child, + ); + }, + child: Container( + width: 90, + height: 90, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFFFE0E3), + boxShadow: [ + BoxShadow( + color: const Color(0xFFE57373).withValues(alpha: 0.25), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: const Icon( + Icons.favorite, + color: Color(0xFFE57373), + size: 40, + ), + ), + ), + ), + const SizedBox(height: 22), + + // Title & Subtitle + Text( + 'Tarik napas dalam-dalam.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 26, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + "EVA menyadari bahwa kamu mungkin membutuhkan dukungan ekstra saat ini. Kamu tidak harus menghadapi ini sendirian.", + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + + // Quick breathing guide trigger button + StaggeredEntrance( + index: 1, + child: GestureDetector( + onTap: _openBreathingExercise, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: const Color(0xFFE57373).withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.air, + size: 16, + color: Color(0xFFE57373), + ), + const SizedBox(width: 8), + Text( + 'Mulai Latihan Napas 4-7-8', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFFE57373), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 24), + + // Action Pill Card 1: WhatsApp Krisis Dinkes DKI + StaggeredEntrance( + index: 2, + child: _buildSupportPillCard( + icon: Icons.support_agent_rounded, + iconBg: const Color(0xFFE8F8F5), + iconColor: const Color(0xFF00B894), + title: 'WhatsApp Krisis Dinkes DKI', + subtitle: 'Pendampingan Konseling Krisis Resmi', + onTap: _openDinkesHotline, + ), + ), + const SizedBox(height: 12), + + // Action Pill Card 2: Hubungi Orang Terdekat + StaggeredEntrance( + index: 3, + child: _buildSupportPillCard( + icon: Icons.perm_contact_calendar_outlined, + iconBg: const Color(0xFFFFDCDD), + iconColor: const Color(0xFFE57373), + title: 'Hubungi Orang Terdekat', + subtitle: contactSubtitle, + onTap: _onTapHubungiOrangTerdekat, + ), + ), + const SizedBox(height: 12), + + // Action Pill Card 3: Hotline Krisis & Bantuan Darurat + StaggeredEntrance( + index: 4, + child: _buildSupportPillCard( + icon: Icons.language, + iconBg: const Color(0xFFD7F3FF), + iconColor: const Color(0xFF00CEC9), + title: 'Hotline Krisis & Bantuan Darurat', + subtitle: 'SEJIWA 119, LISA, 112 Bebas Pulsa', + onTap: _openHotlineModal, + ), + ), + const Spacer(flex: 2), + + // 'Saya merasa lebih baik sekarang' Bottom Button + StaggeredEntrance( + index: 5, + child: GestureDetector( + onTap: _handleDismiss, + child: Container( + width: double.infinity, + height: 54, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: const Color(0xFFE57373).withValues(alpha: 0.4), + width: 1.2, + ), + ), + child: Center( + child: Text( + "Saya merasa lebih baik sekarang", + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildSupportPillCard({ + required IconData icon, + required Color iconBg, + required Color iconColor, + required String title, + String? subtitle, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white, width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: iconBg, + ), + child: Icon(icon, color: iconColor, size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + if (subtitle != null && subtitle.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11.5, + color: AppColors.textSecondary, + fontWeight: FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + const Icon( + Icons.arrow_forward_ios, + size: 15, + color: AppColors.textLight, + ), + const SizedBox(width: 4), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/emergency/presentation/widgets/breathing_exercise_modal.dart b/apps/eva_mobile/lib/features/emergency/presentation/widgets/breathing_exercise_modal.dart new file mode 100644 index 0000000..db15911 --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/widgets/breathing_exercise_modal.dart @@ -0,0 +1,235 @@ +import "dart:async"; +import "package:flutter/material.dart"; +import "package:google_fonts/google_fonts.dart"; + +import "../../../../theme/app_colors.dart"; + +class BreathingExerciseModal extends StatefulWidget { + const BreathingExerciseModal({super.key}); + + @override + State createState() => BreathingExerciseModalState(); +} + +class BreathingExerciseModalState extends State { + Timer? _timer; + int _seconds = 0; + bool _isPlaying = true; + + @override + void initState() { + super.initState(); + _startTimer(); + } + + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return; + setState(() { + _seconds++; + }); + }); + } + + void _togglePlayPause() { + setState(() { + _isPlaying = !_isPlaying; + if (_isPlaying) { + _startTimer(); + } else { + _timer?.cancel(); + } + }); + } + + void _reset() { + setState(() { + _seconds = 0; + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // 4-7-8 Breathing Cycle (Total: 19 seconds per cycle) + final cycle = _seconds % 19; + String phaseText; + String phaseDesc; + Color phaseColor; + double targetScale; + + if (cycle < 4) { + phaseText = 'Tarik Napas'; + phaseDesc = 'Hirup napas perlahan melalui hidung (${4 - cycle}s)'; + phaseColor = const Color(0xFF6C5CE7); + targetScale = 0.85 + (cycle / 4.0) * 0.35; + } else if (cycle < 11) { + final holdLeft = 11 - cycle; + phaseText = 'Tahan Napas'; + phaseDesc = 'Tahan napas di dalam dada ($holdLeft s)'; + phaseColor = const Color(0xFFF59E0B); + targetScale = 1.2; + } else { + final exhaleLeft = 19 - cycle; + phaseText = 'Hembuskan Napas'; + phaseDesc = 'Lepaskan napas perlahan dari mulut ($exhaleLeft s)'; + phaseColor = const Color(0xFF00CEC9); + targetScale = 1.2 - ((cycle - 11) / 8.0) * 0.35; + } + + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Latihan Napas 4-7-8', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'Meredakan panik & menstabilkan detak jantung', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + IconButton( + icon: const Icon(Icons.close, size: 22), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 36), + + // Animated Breathing Circle + SizedBox( + height: 180, + child: Center( + child: AnimatedScale( + scale: targetScale, + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: phaseColor.withValues(alpha: 0.15), + border: Border.all(color: phaseColor, width: 3), + boxShadow: [ + BoxShadow( + color: phaseColor.withValues(alpha: 0.25), + blurRadius: 24, + spreadRadius: 4, + ), + ], + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.air, color: phaseColor, size: 30), + const SizedBox(height: 6), + Text( + phaseText, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: phaseColor, + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + + // Phase Description + Text( + phaseDesc, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 28), + + // Controls + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + onPressed: _reset, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Mulai Ulang'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textSecondary, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: _togglePlayPause, + icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow, size: 18), + label: Text(_isPlaying ? 'Jeda' : 'Lanjutkan'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/emergency/presentation/widgets/contact_form_dialog.dart b/apps/eva_mobile/lib/features/emergency/presentation/widgets/contact_form_dialog.dart new file mode 100644 index 0000000..a2efd6d --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/widgets/contact_form_dialog.dart @@ -0,0 +1,419 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_snack_bar.dart'; + +/// Modal dialog for adding or editing emergency contacts. +class ContactFormDialog extends StatefulWidget { + final Map? contactToEdit; + final void Function({ + required String name, + required String relation, + required String phone, + required bool isPrimary, + Map? contactToEdit, + }) onSave; + + const ContactFormDialog({ + super.key, + this.contactToEdit, + required this.onSave, + }); + + static Future show({ + required BuildContext context, + Map? contactToEdit, + required void Function({ + required String name, + required String relation, + required String phone, + required bool isPrimary, + Map? contactToEdit, + }) onSave, + }) { + return showDialog( + context: context, + builder: (context) => ContactFormDialog( + contactToEdit: contactToEdit, + onSave: onSave, + ), + ); + } + + @override + State createState() => _ContactFormDialogState(); +} + +class _ContactFormDialogState extends State { + late final TextEditingController _nameController; + late final TextEditingController _relationController; + late final TextEditingController _phoneController; + late bool _isPrimary; + + bool get _isEditing => widget.contactToEdit != null; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController( + text: _isEditing ? (widget.contactToEdit!['name'] ?? '') : '', + ); + _relationController = TextEditingController( + text: _isEditing ? (widget.contactToEdit!['relation'] ?? '') : '', + ); + _phoneController = TextEditingController( + text: _isEditing ? (widget.contactToEdit!['phone'] ?? '') : '', + ); + _isPrimary = _isEditing ? (widget.contactToEdit!['isPrimary'] ?? false) : false; + } + + @override + void dispose() { + _nameController.dispose(); + _relationController.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.white, + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _isEditing + ? AppColors.primary.withValues(alpha: 0.12) + : const Color(0xFFFFDCDD), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + _isEditing + ? Icons.edit_note_rounded + : Icons.person_add_alt_1_rounded, + color: _isEditing + ? AppColors.primary + : const Color(0xFFD32F2F), + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isEditing + ? 'Edit Kontak Darurat' + : 'Tambah Kontak Baru', + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + 'Orang terpercaya untuk situasi darurat', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon( + Icons.close_rounded, + size: 20, + color: AppColors.textLight, + ), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 18), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 16), + + // Input 1: Nama Lengkap + _buildFormFieldLabel( + icon: Icons.badge_outlined, + label: 'NAMA LENGKAP', + ), + const SizedBox(height: 6), + TextField( + controller: _nameController, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + decoration: _buildInputDecoration( + hint: 'Misal: Ayah, Ibu, Sahabat', + prefixIcon: Icons.person_outline_rounded, + ), + ), + const SizedBox(height: 14), + + // Input 2: Hubungan / Peran + _buildFormFieldLabel( + icon: Icons.group_outlined, + label: 'HUBUNGAN / PERAN', + ), + const SizedBox(height: 6), + TextField( + controller: _relationController, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + decoration: _buildInputDecoration( + hint: 'Misal: Ibu Kandung, Sahabat Kantor', + prefixIcon: Icons.diversity_1_outlined, + ), + ), + const SizedBox(height: 14), + + // Input 3: Nomor Telepon + _buildFormFieldLabel( + icon: Icons.call_outlined, + label: 'NOMOR TELEPON / WHATSAPP', + ), + const SizedBox(height: 6), + TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + decoration: _buildInputDecoration( + hint: '0812-XXXX-XXXX', + prefixIcon: Icons.phone_android_rounded, + ), + ), + const SizedBox(height: 16), + + // Switch: Kontak Utama + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + decoration: BoxDecoration( + color: _isPrimary + ? const Color(0xFFF0FDF4) + : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: _isPrimary + ? const Color(0xFF86EFAC).withValues(alpha: 0.6) + : const Color(0xFFE2E8F0), + width: 1.2, + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: _isPrimary + ? const Color(0xFFD1FAE5) + : const Color(0xFFF1F5F9), + shape: BoxShape.circle, + ), + child: Icon( + Icons.star_rounded, + size: 18, + color: _isPrimary + ? const Color(0xFF047857) + : AppColors.textLight, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Jadikan Kontak Utama', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + 'Prioritas panggilan darurat pertama', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Switch.adaptive( + value: _isPrimary, + activeThumbColor: const Color(0xFF10B981), + activeTrackColor: const Color(0xFFD1FAE5), + onChanged: (val) { + setState(() { + _isPrimary = val; + }); + }, + ), + ], + ), + ), + const SizedBox(height: 20), + + // Action Buttons + Row( + children: [ + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textSecondary, + side: const BorderSide(color: Color(0xFFE2E8F0)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 13), + ), + onPressed: () => Navigator.pop(context), + child: Text( + 'Batal', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 13), + ), + icon: Icon( + _isEditing + ? Icons.check_rounded + : Icons.add_rounded, + size: 18, + ), + label: Text( + _isEditing ? 'Simpan Perubahan' : 'Tambah Kontak', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + onPressed: () { + final name = _nameController.text.trim(); + final relation = _relationController.text.trim(); + final phone = _phoneController.text.trim(); + + if (name.isEmpty || phone.isEmpty) { + AppSnackBar.showWarning( + context, + 'Nama dan nomor telepon wajib diisi', + duration: const Duration(seconds: 2), + ); + return; + } + + Navigator.pop(context); + widget.onSave( + name: name, + relation: relation, + phone: phone, + isPrimary: _isPrimary, + contactToEdit: _isEditing ? widget.contactToEdit : null, + ); + }, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildFormFieldLabel({required IconData icon, required String label}) { + return Row( + children: [ + Icon(icon, size: 13, color: AppColors.textLight), + const SizedBox(width: 5), + Text( + label, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + ], + ); + } + + InputDecoration _buildInputDecoration({ + required String hint, + required IconData prefixIcon, + }) { + return InputDecoration( + hintText: hint, + hintStyle: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textLight, + ), + prefixIcon: Icon(prefixIcon, size: 18, color: AppColors.textSecondary), + filled: true, + fillColor: const Color(0xFFF8FAFC), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_contacts_skeleton_loader.dart b/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_contacts_skeleton_loader.dart new file mode 100644 index 0000000..69f9fef --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_contacts_skeleton_loader.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../../../../widgets/glass_card.dart'; +import '../../../../widgets/skeleton_shimmer.dart'; + +/// Shimmer skeleton loader for Emergency Contacts list. +class EmergencyContactsSkeletonLoader extends StatelessWidget { + const EmergencyContactsSkeletonLoader({super.key}); + + @override + Widget build(BuildContext context) { + return SkeletonShimmerHost( + child: Column( + children: List.generate( + 3, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SkeletonBox(width: 44, height: 44, borderRadius: 14), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 140, height: 16), + SizedBox(height: 6), + SkeletonLine(width: 90, height: 12), + ], + ), + ), + const SkeletonBox(width: 60, height: 22, borderRadius: 6), + ], + ), + const SizedBox(height: 14), + Row( + children: const [ + SkeletonBox(width: 130, height: 28, borderRadius: 8), + Spacer(), + SkeletonCircle(size: 32), + SizedBox(width: 8), + SkeletonCircle(size: 32), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_hotline_modal.dart b/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_hotline_modal.dart new file mode 100644 index 0000000..50c0b81 --- /dev/null +++ b/apps/eva_mobile/lib/features/emergency/presentation/widgets/emergency_hotline_modal.dart @@ -0,0 +1,207 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:google_fonts/google_fonts.dart"; + +import "../../../../theme/app_colors.dart"; +import "../../../../widgets/app_snack_bar.dart"; + +class EmergencyHotlineModal extends StatelessWidget { + const EmergencyHotlineModal({super.key}); + + void _copy(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + AppSnackBar.showSuccess( + context, + '$label ($text) disalin ke clipboard', + duration: const Duration(seconds: 2), + ); + } + + @override + Widget build(BuildContext context) { + final hotlines = [ + { + 'title': 'Layanan SEJIWA (Kemenkes)', + 'desc': 'Hotline kesehatan mental & pencegahan krisis', + 'number': '119', + 'display': '119 (Ext. 8)', + 'icon': Icons.health_and_safety_outlined, + }, + { + 'title': 'LISA - Sahabat Jiwa', + 'desc': 'Bantuan darurat kesehatan mental 24 Jam', + 'number': '08113855472', + 'display': '0811-3855-472', + 'icon': Icons.phone_in_talk_outlined, + }, + { + 'title': 'Nomor Darurat Nasional', + 'desc': 'Panggilan darurat terpadu bebas pulsa', + 'number': '112', + 'display': '112', + 'icon': Icons.emergency_outlined, + }, + { + 'title': 'Ambulans Kedaruratan Medis', + 'desc': 'Layanan tanggap darurat medis cepat', + 'number': '118', + 'display': '118 / 119', + 'icon': Icons.local_hospital_outlined, + }, + { + 'title': 'Kepolisian Republik Indonesia', + 'desc': 'Kedaruratan keamanan & keselamatan jiwa', + 'number': '110', + 'display': '110', + 'icon': Icons.local_police_outlined, + }, + { + 'title': 'Halo Kemenkes', + 'desc': 'Informasi fasilitas layanan kesehatan resmi', + 'number': '1500567', + 'display': '1500-567', + 'icon': Icons.info_outline, + }, + ]; + + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 28), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 18), + + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Hotline Krisis & Darurat', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 22), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Tekan ikon salin untuk menyalin nomor langsung ke dialer telepon.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 16), + + // List + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: hotlines.length, + separatorBuilder: (context, index) => const SizedBox(height: 10), + itemBuilder: (ctx, i) { + final h = hotlines[i]; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFD7F3FF), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + h['icon'] as IconData, + color: const Color(0xFF00CEC9), + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + h['title'] as String, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + h['desc'] as String, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + h['display'] as String, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w800, + color: const Color(0xFF00838F), + ), + ), + ], + ), + ), + IconButton( + icon: const Icon( + Icons.copy, + size: 18, + color: AppColors.textSecondary, + ), + tooltip: 'Salin nomor', + onPressed: () => _copy( + context, + h['number'] as String, + h['title'] as String, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} + + diff --git a/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart b/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart new file mode 100644 index 0000000..3f93aed --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart @@ -0,0 +1,1629 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/core/utils/responsive_layout_helper.dart'; +import 'package:eva_mobile/features/auth/domain/entities/user_entity.dart'; +import 'package:eva_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:eva_mobile/features/chat/domain/entities/conversation_entity.dart'; +import 'package:eva_mobile/features/chat/presentation/providers/chat_provider.dart'; +import 'package:eva_mobile/features/dass/domain/entities/dass_assessment_entity.dart'; +import 'package:eva_mobile/features/dass/presentation/providers/dass_provider.dart'; +import 'package:eva_mobile/features/diary/domain/entities/diary_entry_entity.dart'; +import 'package:eva_mobile/features/diary/presentation/providers/diary_provider.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/home_activity_modal.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/profile_dropdown_modal.dart'; +import 'package:eva_mobile/features/recommendations/presentation/providers/recommendation_provider.dart'; +import 'package:eva_mobile/providers/daily_progress_provider.dart'; +import 'package:eva_mobile/services/daily_progress_local_service.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/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'; + String _userEmail = 'samsul@gmail.com'; + int _todayConversationsCount = 0; + + String get _userInitials { + final parts = _userName.trim().split(RegExp(r'\s+')); + if (parts.length >= 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } else if (parts.isNotEmpty && parts[0].isNotEmpty) { + return parts[0].substring(0, parts[0].length >= 2 ? 2 : 1).toUpperCase(); + } + return 'SA'; + } + bool _hasTodayDiary = false; + String _aiInsight = + 'Setiap perasaan yang kamu alami hari ini adalah valid. Luangkan waktu sejenak untuk menarik napas dalam dan menyayangi dirimu sendiri.'; + + DailyProgressData _localProgress = const DailyProgressData(date: ''); + bool _hasTodayDass = false; + bool _dassVerifiedByUser = false; + String _dassStatus = ''; + int _dassStressScore = 0; + int _dassAnxietyScore = 0; + int _dassDepressionScore = 0; + String _targetCondition = 'general'; + String _conditionLabel = 'Fokus: Relaksasi & Perawatan Diri'; + String _conditionReason = 'Menjaga ritme emosi dan ketenangan pikiran harian'; + String _severityLevel = 'Normal'; + bool _isCrisis = false; + String _crisisHotline = 'Kemenkes 119 ext 8 / LISA 0811-3855-472'; + String _crisisGuidance = + 'Keselamatan dan ketenanganmu adalah prioritas paling berharga. Silakan hubungi bantuan darurat bila beban terasa terlalu berat.'; + Map? _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(); + final cachedEmail = await AppConfig.getUserEmail(); + if (cachedName != null && cachedName.isNotEmpty && mounted) { + setState(() => _userName = cachedName); + } + if (cachedEmail != null && cachedEmail.isNotEmpty && mounted) { + _userEmail = cachedEmail; + } + + // 2. Load today's local self-care progress (auto-reset if new day) + final localData = await DailyProgressLocalService.loadTodayProgress(); + if (mounted) { + setState(() => _localProgress = localData); + } + + try { + final results = await Future.wait([ + // 0: Profile + ref.read(authRepositoryProvider).getCurrentUser().catchError((_) => null), + // 1: Today conversations + ref.read(chatRepositoryProvider).getConversations().catchError((_) => []), + // 2: Today diary & emotions + ref.read(diaryRepositoryProvider).getTodayDiary().catchError((_) => null), + // 3: Tailored recommendation for today + ref.read(recommendationRepositoryProvider).getTodayRecommendation().catchError((_) => {}), + // 4: Today DASS-21 assessment + ref.read(dassRepositoryProvider).getTodayAssessment().then((a) => a).catchError((_) => null), + ]); + + if (!mounted) return; + + // Handle Profile + final user = results[0] as UserEntity?; + if (user != null) { + _userName = user.name; + if (user.email.isNotEmpty) { + _userEmail = user.email; + } + } + + // Handle Today Conversations + final conversations = results[1] as List? ?? []; + _todayConversationsCount = conversations.length; + if (_todayConversationsCount > 0) { + _localProgress = await DailyProgressLocalService.recordConversationCheckin( + hasConversation: true, + ); + } + + // Handle Today Diary + final diary = results[2] as DiaryEntryEntity?; + if (diary != null) { + _hasTodayDiary = true; + _localProgress = await DailyProgressLocalService.recordDiaryCheckin( + hasDiary: true, + ); + if (diary.aiInsight.isNotEmpty) { + _aiInsight = diary.aiInsight; + } + } + + // Handle Today Tailored Recommendation + final recData = results[3] as Map? ?? {}; + if (recData['todayActivity'] != null) { + _targetCondition = recData['targetCondition']?.toString() ?? 'general'; + _conditionLabel = + recData['conditionLabel']?.toString() ?? 'Fokus: Relaksasi & Perawatan Diri'; + _conditionReason = recData['conditionReason']?.toString() ?? ''; + _severityLevel = recData['severityLevel']?.toString() ?? 'Normal'; + _isCrisis = recData['isCrisis'] == true; + if (recData['crisisHotline'] != null) { + _crisisHotline = recData['crisisHotline'].toString(); + } + if (recData['crisisGuidance'] != null) { + _crisisGuidance = recData['crisisGuidance'].toString(); + } + + final act = recData['todayActivity'] as Map; + 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 (recData['alternatives'] is List) { + for (var item in recData['alternatives']) { + final altId = item['id']?.toString() ?? ''; + combined.add({ + 'id': altId, + 'title': item['title']?.toString() ?? 'Rekomendasi', + 'category': item['category']?.toString() ?? 'Self-Care', + 'targetCondition': + item['targetCondition']?.toString() ?? _targetCondition, + 'duration': item['duration']?.toString() ?? '5 menit', + 'difficulty': item['difficulty']?.toString() ?? 'Pemula', + 'description': item['description']?.toString() ?? '', + 'instructions': [], + 'rationale': '', + 'isCompleted': item['isCompleted'] == true || + _localProgress.isActivityCompleted(altId), + }); + } + } + _recommendations = combined; + + if (isActDone && !_localProgress.isActivityCompleted(actId)) { + _localProgress = + await DailyProgressLocalService.recordActivityCompletion( + actId, + true, + ); + } + } + + // Handle Today DASS-21 Assessment + final dass = results[4] as DASSAssessmentEntity?; + if (dass != null) { + _hasTodayDass = dass.id?.isNotEmpty == true; + _dassVerifiedByUser = dass.verifiedByUser; + _dassStatus = dass.status; + _dassStressScore = dass.stressScore; + _dassAnxietyScore = dass.anxietyScore; + _dassDepressionScore = dass.depressionScore; + } + } catch (_) {} + + if (mounted) setState(() {}); + } + + Future _toggleRecommendationComplete(String id) async { + bool currentStatus = false; + final idx = _recommendations.indexWhere((r) => r['id'] == id); + if (idx != -1) { + currentStatus = _recommendations[idx]['isCompleted'] == true; + setState(() { + _recommendations[idx]['isCompleted'] = !currentStatus; + }); + } + + if (_todayActivity != null && _todayActivity!['id'] == id) { + currentStatus = _todayActivity!['isCompleted'] == true; + setState(() { + _todayActivity!['isCompleted'] = !currentStatus; + }); + } + + final newStatus = !currentStatus; + + final updatedProgress = + await DailyProgressLocalService.recordActivityCompletion( + id, + newStatus, + ); + if (mounted) { + setState(() { + _localProgress = updatedProgress; + }); + } + + if (newStatus) { + try { + await ref.read(recommendationRepositoryProvider).markAsCompleted(id); + } catch (_) {} + } + } + + int get _completedRoutinesCount { + int count = 0; + if (_localProgress.hasConversation || _todayConversationsCount > 0) count++; + if (_localProgress.hasDiary || _hasTodayDiary) count++; + final bool activityDone = _localProgress.completedActivityIds.isNotEmpty || + (_todayActivity != null && _todayActivity!['isCompleted'] == true) || + _recommendations.any((r) => r['isCompleted'] == true); + if (activityDone) count++; + return count; + } + + void _showProfileDropdown(BuildContext context) { + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'ProfileDropdown', + barrierColor: Colors.black.withValues(alpha: 0.3), + transitionDuration: const Duration(milliseconds: 220), + pageBuilder: (dialogContext, anim1, anim2) { + return SafeArea( + child: Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(top: 50.0, right: 20.0), + child: Material( + color: Colors.transparent, + child: ProfileDropdownModal( + userName: _userName, + userEmail: _userEmail, + onProfileTap: () { + Navigator.pop(dialogContext); + Navigator.pushNamed(context, '/user_profile').then((_) { + if (mounted) _loadHomeScreenData(); + }); + }, + onEmergencyContactsTap: () { + Navigator.pop(dialogContext); + Navigator.pushNamed(context, '/emergency_contacts'); + }, + onSettingsTap: () { + Navigator.pop(dialogContext); + Navigator.pushNamed(context, '/settings'); + }, + onLogoutTap: () async { + Navigator.pop(dialogContext); + await AppConfig.clearToken(); + if (context.mounted) { + Navigator.pushNamedAndRemoveUntil(context, '/login', (route) => false); + } + }, + ), + ), + ), + ), + ); + }, + transitionBuilder: (dialogContext, anim1, anim2, child) { + final curved = CurvedAnimation(parent: anim1, curve: Curves.easeOutBack); + return ScaleTransition( + alignment: Alignment.topRight, + scale: Tween(begin: 0.85, end: 1.0).animate(curved), + child: FadeTransition( + opacity: anim1, + child: child, + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final liveProgress = ref.watch(dailyProgressProvider); + _localProgress = liveProgress; + + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: RefreshIndicator( + color: AppColors.primary, + onRefresh: _loadHomeScreenData, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20.0, 16.0, 20.0, 110.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // User Greeting Header with Space Between & Profile Photo + StaggeredEntrance( + index: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Halo, $_userName! 👋', + style: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Bagaimana perasaanmu hari ini?', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () => _showProfileDropdown(context), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [Color(0xFF8B93FF), Color(0xFF5358CB)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.25), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + border: Border.all(color: Colors.white, width: 2), + ), + child: Center( + child: Text( + _userInitials, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 18), + + // Empathetic AI Psychologist Insight Card + StaggeredEntrance( + index: 2, + child: _buildLunaInsightCard(), + ), + 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 _buildLunaInsightCard() { + 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) { + AppSnackBar.showPrimary( + context, + 'Kamu sudah menyelesaikan latihan ini hari ini! Kerja bagus, istirahatlah sejenak dan lanjutkan kembali besok 🌿', + icon: Icons.info_outline, + ); + 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) { + AppSnackBar.showPrimary( + context, + 'Kamu sudah menyelesaikan latihan ini hari ini! Kerja bagus, istirahatlah sejenak dan lanjutkan kembali besok 🌿', + icon: Icons.info_outline, + ); + Navigator.pop(ctx); + return; + } + await _toggleRecommendationComplete(id); + if (context.mounted) { + AppSnackBar.showSuccess( + context, + 'Bagus sekali! Satu langkah kecil untuk ketenanganmu telah selesai.', + icon: Icons.check_circle, + ); + Navigator.pop(ctx); + } + }, + ); + }, + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/screens/main_shell_screen.dart b/apps/eva_mobile/lib/features/home/presentation/screens/main_shell_screen.dart new file mode 100644 index 0000000..5ef3f48 --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/screens/main_shell_screen.dart @@ -0,0 +1,429 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/floating_nav_bar.dart'; +import 'package:eva_mobile/features/diary/presentation/screens/ai_diary_screen.dart'; +import 'home_screen.dart'; +import 'package:eva_mobile/features/monitoring/presentation/screens/monitoring_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/care_screen.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/presentation/providers/tutorial_provider.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; + +/// Lightweight wrapper ensuring each tab maintains its state and scroll position across switches. +class _KeepAliveWrapper extends StatefulWidget { + final Widget child; + + const _KeepAliveWrapper({required this.child}); + + @override + State<_KeepAliveWrapper> createState() => _KeepAliveWrapperState(); +} + +class _KeepAliveWrapperState extends State<_KeepAliveWrapper> + with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } +} + +class MainShellScreen extends ConsumerStatefulWidget { + final int initialIndex; + + const MainShellScreen({ + super.key, + this.initialIndex = 0, + }); + + @override + ConsumerState createState() => _MainShellScreenState(); +} + +class _MainShellScreenState extends ConsumerState { + late int _currentIndex; + bool _isNavBarVisible = true; + late final PageController _pageController; + + final GlobalKey _homeKey = GlobalKey(); + final GlobalKey _diaryKey = GlobalKey(); + final GlobalKey _monitoringKey = GlobalKey(); + final GlobalKey _careKey = GlobalKey(); + + final GlobalKey _centerButtonKey = GlobalKey(debugLabel: 'centerButton'); + final GlobalKey _journalTabKey = GlobalKey(debugLabel: 'journalTab'); + final GlobalKey _trenTabKey = GlobalKey(debugLabel: 'trenTab'); + final GlobalKey _careTabKey = GlobalKey(debugLabel: 'careTab'); + + 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: CareScreen(key: _careKey), + ), + ]; + + if (widget.initialIndex == 0) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(tutorialNotifierProvider.notifier).ensureActiveOnLaunch(); + }); + } else if (widget.initialIndex == 1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final tutorialState = ref.read(tutorialNotifierProvider); + if (!tutorialState.isCompleted && + tutorialState.currentStep == TutorialStep.journal) { + ref + .read(tutorialNotifierProvider.notifier) + .resumeAtStep(TutorialStep.journal); + } + }); + } + } + + GlobalKey? _getTargetKeyForStep(TutorialStep step) { + switch (step) { + case TutorialStep.voiceSession: + return _centerButtonKey; + case TutorialStep.journal: + return _journalTabKey; + case TutorialStep.analytics: + return _trenTabKey; + case TutorialStep.care: + return _careTabKey; + } + } + + void _handleCenterButtonTap() { + final tutorialState = ref.read(tutorialNotifierProvider); + if (tutorialState.isActive && + tutorialState.currentStep == TutorialStep.voiceSession) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceSession); + } + Navigator.pushNamed(context, '/chat'); + } + + void _onTabTapped(int index) { + if (index == _currentIndex) { + // Tapping same tab = refresh + _triggerRefresh(index); + return; + } + + setState(() { + _currentIndex = index; + _isNavBarVisible = true; + }); + + _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: + 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) { + final tutorialState = ref.watch(tutorialNotifierProvider); + + 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 && + !tutorialState.isActive) { + setState(() { + _isNavBarVisible = false; + }); + } else if (notification.direction == ScrollDirection.forward && + !_isNavBarVisible) { + setState(() { + _isNavBarVisible = true; + }); + } + return true; + }, + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + children: _pages, + ), + ), + ), + ), + + // Animated Floating Navigation Bar (Slides down when scrolling down, slides up on scroll up) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: AnimatedSlide( + offset: _isNavBarVisible ? Offset.zero : const Offset(0, 2.0), + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: FloatingNavBar( + currentIndex: _currentIndex, + onTap: _onTabTapped, + onCenterTap: _handleCenterButtonTap, + centerButtonKey: _centerButtonKey, + journalTabKey: _journalTabKey, + trenTabKey: _trenTabKey, + careTabKey: _careTabKey, + ), + ), + ), + ), + ), + + // Branded Guidance Card + Interactive Spotlight Overlay + if (tutorialState.isActive && tutorialState.currentStep != null) + InteractiveSpotlightOverlay( + key: ValueKey(tutorialState.currentStep), + targetKey: _getTargetKeyForStep(tutorialState.currentStep!), + content: TutorialContent.stepContents[tutorialState.currentStep!]!, + onSkip: () => + ref.read(tutorialNotifierProvider.notifier).skipTutorial(), + onNext: () { + final current = tutorialState.currentStep; + if (current == TutorialStep.journal) { + _onTabTapped(2); + } else if (current == TutorialStep.analytics) { + _onTabTapped(3); + } + ref.read(tutorialNotifierProvider.notifier).nextStep(); + }, + onTargetTap: () { + if (tutorialState.currentStep == TutorialStep.voiceSession) { + _handleCenterButtonTap(); + } else { + ref.read(tutorialNotifierProvider.notifier).nextStep(); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_activity_modal.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_activity_modal.dart new file mode 100644 index 0000000..59c8e31 --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_activity_modal.dart @@ -0,0 +1,445 @@ +import "dart:async"; +import "package:flutter/material.dart"; +import "package:google_fonts/google_fonts.dart"; + +import "../../../../theme/app_colors.dart"; + +/// Modal bottom sheet content for home screen with rationale, steps, and breath pacer +class HomeActivityModalContent extends StatefulWidget { + final Map activity; + final String conditionReason; + final bool isCompleted; + final VoidCallback onComplete; + + const HomeActivityModalContent({ + super.key, + required this.activity, + required this.conditionReason, + required this.isCompleted, + required this.onComplete, + }); + + @override + State createState() => + HomeActivityModalContentState(); +} + +class HomeActivityModalContentState extends State { + 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/features/home/presentation/widgets/profile_dropdown_modal.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/profile_dropdown_modal.dart new file mode 100644 index 0000000..4072668 --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/profile_dropdown_modal.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; + +/// Floating profile dropdown modal inspired by modern glass design, +/// styled specifically to match Gambar 2 with Eva AI theme. +class ProfileDropdownModal extends StatelessWidget { + final String userName; + final String userEmail; + final VoidCallback onProfileTap; + final VoidCallback onEmergencyContactsTap; + final VoidCallback onSettingsTap; + final VoidCallback onLogoutTap; + + const ProfileDropdownModal({ + super.key, + required this.userName, + required this.userEmail, + required this.onProfileTap, + required this.onEmergencyContactsTap, + required this.onSettingsTap, + required this.onLogoutTap, + }); + + String get _userInitials { + final parts = userName.trim().split(RegExp(r'\s+')); + if (parts.length >= 2 && parts[0].isNotEmpty && parts[1].isNotEmpty) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } else if (parts.isNotEmpty && parts[0].isNotEmpty) { + return parts[0].substring(0, parts[0].length >= 2 ? 2 : 1).toUpperCase(); + } + return 'SA'; + } + + @override + Widget build(BuildContext context) { + return Container( + width: 295, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: const Color(0xFFE2E8F0), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 28, + offset: const Offset(0, 10), + ), + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.08), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 1. User Info Header + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [Color(0xFF8B93FF), Color(0xFF5358CB)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: Border.all( + color: const Color(0xFFE0E7FF), + width: 2, + ), + ), + child: Center( + child: Text( + _userInitials, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + userName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + userEmail.isNotEmpty ? userEmail : 'samsul@gmail.com', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 14), + + // 2. Pro Upgrade Gradient Banner (matching Gambar 2) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF2563EB), Color(0xFF06B6D4)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: const Color(0xFF2563EB).withValues(alpha: 0.25), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + const Icon( + Icons.workspace_premium_rounded, + color: Color(0xFFFFD54F), + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Upgrade profile', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'PRO', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w900, + color: const Color(0xFF1E293B), + letterSpacing: 0.5, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 12), + + // 3. Menu Item: User Profile + _buildMenuItem( + icon: Icons.person_outline_rounded, + title: 'User Profile', + onTap: onProfileTap, + ), + + // 4. Menu Item: Kontak Darurat + _buildMenuItem( + icon: Icons.contact_phone_outlined, + title: 'Kontak Darurat', + onTap: onEmergencyContactsTap, + ), + + // 5. Menu Item: Pengaturan + _buildMenuItem( + icon: Icons.settings_outlined, + title: 'Settings', + onTap: onSettingsTap, + ), + + const SizedBox(height: 6), + const Divider( + height: 16, + thickness: 1, + color: Color(0xFFF1F5F9), + ), + const SizedBox(height: 4), + + // 6. Menu Item: Keluar Akun (Red accent matching bottom item in Gambar 2) + InkWell( + onTap: onLogoutTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: const Color(0xFFFEE2E2), + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.logout_rounded, + size: 18, + color: Color(0xFFEF4444), + ), + const SizedBox(width: 10), + Text( + 'Keluar Akun', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFFEF4444), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildMenuItem({ + required IconData icon, + required String title, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: const Color(0xFF64748B), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + const Icon( + Icons.arrow_forward_ios_rounded, + size: 12, + color: Color(0xFFCBD5E1), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/monitoring/data/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/fallback_monitoring_repository.dart b/apps/eva_mobile/lib/features/monitoring/data/repositories/fallback_monitoring_repository.dart new file mode 100644 index 0000000..75a7a0a --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/data/repositories/fallback_monitoring_repository.dart @@ -0,0 +1,23 @@ +import '../../domain/repositories/monitoring_repository.dart'; +import '../../domain/entities/monitoring_data_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackMonitoringRepository implements MonitoringRepository { + final MonitoringRepository _remote; + final MonitoringRepository _mock; + + FallbackMonitoringRepository({ + required MonitoringRepository remote, + required MonitoringRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future getMonitoringData(String periodKey) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getMonitoringData(periodKey), + mockCall: () => _mock.getMonitoringData(periodKey), + operationName: 'getMonitoringData($periodKey)', + ); + } +} diff --git a/apps/eva_mobile/lib/features/monitoring/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..aa69eaf --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/presentation/providers/monitoring_provider.dart @@ -0,0 +1,29 @@ +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'; +import '../../data/repositories/fallback_monitoring_repository.dart'; + +final monitoringRepositoryProvider = Provider((ref) { + final mockRepo = MonitoringRepositoryImpl(MockMonitoringDataSource()); + final remoteRepo = MonitoringRepositoryImpl(RemoteMonitoringDataSource()); + + if (AppConfig.useMockData) { + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackMonitoringRepository(remote: remoteRepo, mock: mockRepo); + } else { + return remoteRepo; + } +}); + +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/monitoring/presentation/screens/monitoring_screen.dart b/apps/eva_mobile/lib/features/monitoring/presentation/screens/monitoring_screen.dart new file mode 100644 index 0000000..23573ed --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/presentation/screens/monitoring_screen.dart @@ -0,0 +1,772 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/core/utils/responsive_layout_helper.dart'; +import 'package:eva_mobile/features/dass/presentation/providers/dass_provider.dart'; +import 'package:eva_mobile/features/monitoring/domain/entities/monitoring_data_entity.dart'; +import 'package:eva_mobile/features/monitoring/presentation/providers/monitoring_provider.dart'; +import 'package:eva_mobile/features/monitoring/presentation/widgets/monitoring_dass_card.dart'; +import 'package:eva_mobile/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/skeleton_shimmer.dart'; + +class MonitoringScreen extends ConsumerStatefulWidget { + 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: RefreshIndicator( + color: AppColors.primary, + onRefresh: () async { + ref.invalidate(monitoringDataProvider); + try { + await ref.read(monitoringDataProvider.future); + } catch (_) {} + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20.0, 16.0, 20.0, 100.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title & Subtitle + Text( + 'Ritem & Tren Emosional', + style: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Grafik dinamika 7 emosi dan tingkat risiko kesehatan mentalmu.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 16), + + // PERIOD FILTER CHIPS + Row( + children: [ + _buildPeriodChip('today', 'Hari Ini', selectedPeriod), + const SizedBox(width: 8), + _buildPeriodChip('week', 'Minggu Ini', selectedPeriod), + const SizedBox(width: 8), + _buildPeriodChip('month', 'Bulan Ini', selectedPeriod), + ], + ), + const SizedBox(height: 20), + + // ASYNC CONTENT + asyncData.when( + data: (data) => _buildMonitoringContent(context, data), + loading: () => _buildLoadingState(), + error: (error, _) => _buildErrorState(error), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildPeriodChip(String id, String label, String activePeriod) { + final isSelected = id == activePeriod; + return Expanded( + child: GestureDetector( + onTap: () { + ref.read(selectedPeriodProvider.notifier).state = id; + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(999), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: Text( + label, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: isSelected ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ), + ); + } + + Widget _buildMonitoringContent(BuildContext context, MonitoringDataEntity data) { + final emotionalCenter = data.emotionalCenter; + final int level = emotionalCenter.level; + final Color textColor = _parseHex(emotionalCenter.textColorHex, const Color(0xFF2E7D32)); + final List 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) + MonitoringDassCard(selectedPeriod: ref.watch(selectedPeriodProvider)), + ], + ); + } + + + 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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/monitoring/presentation/widgets/monitoring_dass_card.dart b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/monitoring_dass_card.dart new file mode 100644 index 0000000..c392e4b --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/monitoring_dass_card.dart @@ -0,0 +1,946 @@ +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:google_fonts/google_fonts.dart"; +import "package:url_launcher/url_launcher.dart"; + +import "package:eva_mobile/theme/app_colors.dart"; +import "package:eva_mobile/widgets/app_snack_bar.dart"; +import "package:eva_mobile/widgets/glass_card.dart"; +import "../../../dass/presentation/providers/dass_provider.dart"; + +class MonitoringDassCard extends ConsumerWidget { + final String selectedPeriod; + + const MonitoringDassCard({ + super.key, + required this.selectedPeriod, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final dassState = ref.watch(dassAssessmentNotifierProvider); + + return dassState.assessment.when( + data: (assessment) { + final bool hasValidAssessment = assessment.id != null && + assessment.status != "unassessed" && + assessment.status.isNotEmpty && + (assessment.verifiedByUser || assessment.status == "auto_extracted"); + + final String subtitleText = hasValidAssessment + ? (assessment.verifiedByUser + ? "Skor DASS-21 telah diverifikasi ✓" + : "Tersintesis otomatis • Ketuk untuk meninjau") + : "Belum ada evaluasi hari ini"; + + final int? stressScore = hasValidAssessment ? assessment.stressScore : null; + final String stressSeverity = hasValidAssessment ? assessment.stressSeverity : "unassessed"; + + final int? anxietyScore = hasValidAssessment ? assessment.anxietyScore : null; + final String anxietySeverity = hasValidAssessment ? assessment.anxietySeverity : "unassessed"; + + final int? depressionScore = hasValidAssessment ? assessment.depressionScore : null; + final String depressionSeverity = hasValidAssessment ? assessment.depressionSeverity : "unassessed"; + + return _buildDASSLauncherCardContainer( + context: context, + ref: ref, + selectedPeriod: selectedPeriod, + subtitleText: subtitleText, + hasValidAssessment: hasValidAssessment, + stressScore: stressScore, + stressSeverity: stressSeverity, + anxietyScore: anxietyScore, + anxietySeverity: anxietySeverity, + depressionScore: depressionScore, + depressionSeverity: depressionSeverity, + ); + }, + loading: () => _buildDASSLauncherCardContainer( + context: context, + ref: ref, + selectedPeriod: selectedPeriod, + subtitleText: "Memuat data evaluasi DASS-21...", + hasValidAssessment: false, + stressScore: null, + stressSeverity: "unassessed", + anxietyScore: null, + anxietySeverity: "unassessed", + depressionScore: null, + depressionSeverity: "unassessed", + ), + error: (_, _) => _buildDASSLauncherCardContainer( + context: context, + ref: ref, + selectedPeriod: selectedPeriod, + subtitleText: "Belum ada evaluasi hari ini", + hasValidAssessment: false, + stressScore: null, + stressSeverity: "unassessed", + anxietyScore: null, + anxietySeverity: "unassessed", + depressionScore: null, + depressionSeverity: "unassessed", + ), + ); + } + + Widget _buildDASSLauncherCardContainer({ + required BuildContext context, + required WidgetRef ref, + required String selectedPeriod, + required String subtitleText, + required bool hasValidAssessment, + required int? stressScore, + required String stressSeverity, + required int? anxietyScore, + required String anxietySeverity, + required int? depressionScore, + required String depressionSeverity, + }) { + return 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}, + ); + ref.invalidate(dassAssessmentNotifierProvider); + }, + icon: Icon( + hasValidAssessment + ? Icons.assignment_turned_in_outlined + : Icons.edit_note_rounded, + size: 18, + ), + label: Text( + hasValidAssessment + ? (selectedPeriod == 'today' + ? 'Tinjau Asesmen Hari Ini' + : 'Buka Riwayat Asesmen') + : 'Mulai Evaluasi DASS-21', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + ), + ), + ], + ), + ), + ); + } + + String _getIndonesianSeverity(String scaleType, int score, String rawSeverity) { + final s = rawSeverity.toLowerCase(); + if (s.contains('extremely') || s.contains('sangat berat')) return 'Sangat Berat'; + if (s.contains('severe') || s.contains('berat')) return 'Berat'; + if (s.contains('moderate') || s.contains('sedang')) return 'Sedang'; + if (s.contains('mild') || s.contains('ringan')) return 'Ringan'; + if (s.contains('normal')) return 'Normal'; + + if (scaleType == 'depression') { + if (score >= 28) return 'Sangat Berat'; + if (score >= 21) return 'Berat'; + if (score >= 14) return 'Sedang'; + if (score >= 10) return 'Ringan'; + return 'Normal'; + } else if (scaleType == 'anxiety') { + if (score >= 20) return 'Sangat Berat'; + if (score >= 15) return 'Berat'; + if (score >= 10) return 'Sedang'; + if (score >= 8) return 'Ringan'; + return 'Normal'; + } else { + if (score >= 34) return 'Sangat Berat'; + if (score >= 26) return 'Berat'; + if (score >= 19) return 'Sedang'; + if (score >= 15) return 'Ringan'; + return 'Normal'; + } + } + + Color _getScoreColor(String severityIndo) { + switch (severityIndo) { + case 'Sangat Berat': + return const Color(0xFFD32F2F); + case 'Berat': + return const Color(0xFFE65100); + case 'Sedang': + return const Color(0xFFF57C00); + case 'Ringan': + return const Color(0xFFFBC02D); + case 'Normal': + default: + return const Color(0xFF2E7D32); + } + } + + Widget _buildSubscaleMiniChip( + BuildContext context, + String label, + int? score, + String rawSeverity, + String scaleType, + ) { + if (score == null) { + return Expanded( + child: GestureDetector( + onTap: () => _showDassInfoDialog(context), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FE), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: const Color(0xFFE5E7EB), + width: 1, + ), + ), + child: Column( + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: const Color(0xFF6B7280), + ), + ), + const SizedBox(height: 3), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '-', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w900, + color: const Color(0xFF9CA3AF), + ), + ), + Text( + '/42', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w600, + color: const Color(0xFF9CA3AF), + ), + ), + ], + ), + const SizedBox(height: 2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Belum Ada', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w700, + color: const Color(0xFF9CA3AF), + ), + ), + ), + ], + ), + ), + ), + ); + } + + final severityIndo = _getIndonesianSeverity(scaleType, score, rawSeverity); + final scoreColor = _getScoreColor(severityIndo); + final bool isExtremelySevere = severityIndo == 'Sangat Berat'; + + final Color bgColor = isExtremelySevere + ? const Color(0xFFD32F2F) + : scoreColor.withValues(alpha: 0.08); + + final Color labelColor = isExtremelySevere ? Colors.white70 : scoreColor; + + return Expanded( + child: GestureDetector( + onTap: () { + if (isExtremelySevere) { + _showPsychologistReferralModal(context, label, score, scaleType); + } else { + _showSubscaleInfoModal(context, label, score, severityIndo, scaleType); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isExtremelySevere + ? const Color(0xFFB71C1C) + : scoreColor.withValues(alpha: 0.25), + width: isExtremelySevere ? 1.5 : 1, + ), + boxShadow: isExtremelySevere + ? [ + BoxShadow( + color: const Color(0xFFD32F2F).withValues(alpha: 0.35), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isExtremelySevere) ...[ + const Icon(Icons.warning_amber_rounded, size: 12, color: Colors.white), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: labelColor, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$score', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w900, + color: isExtremelySevere ? Colors.white : scoreColor, + ), + ), + Text( + '/42', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w600, + color: isExtremelySevere ? Colors.white70 : AppColors.textLight, + ), + ), + ], + ), + const SizedBox(height: 2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: isExtremelySevere + ? Colors.white.withValues(alpha: 0.2) + : scoreColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + severityIndo, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 8.5, + fontWeight: FontWeight.w800, + color: isExtremelySevere ? Colors.white : scoreColor, + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _showDassInfoDialog(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Icon(Icons.info_outline, color: AppColors.primary, size: 22), + const SizedBox(width: 8), + Text( + 'Panduan Skala DASS-21', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'DASS-21 (Depression Anxiety Stress Scales) mengukur 3 indikator psikologis harian (0–42 poin per subskala) dari transkrip percakapanmu bersama Eva AI.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.45, + ), + ), + const SizedBox(height: 16), + Text( + 'KATEGORI WARNA & TINGKAT KEPARAHAN:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + _buildLegendRow('Normal', 'Kondisi emosi stabil & terkendali.', const Color(0xFF2E7D32)), + _buildLegendRow('Ringan (Mild)', 'Fluktuasi emosi ringan harian.', const Color(0xFFFBC02D)), + _buildLegendRow('Sedang (Moderate)', 'Mulai mengganggu kenyamanan & butuh jeda istirahat.', const Color(0xFFF57C00)), + _buildLegendRow('Berat (Severe)', 'Beban emosional terasa berat & perlu perhatian khusus.', const Color(0xFFE65100)), + _buildLegendRow('Sangat Berat (Extremely Severe)', 'Indikasi krisis — sangat disarankan konsultasi ke psikolog/psikiater.', const Color(0xFFD32F2F)), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Saya Mengerti'), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildLegendRow(String label, String desc, Color color) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text( + label, + style: GoogleFonts.inter(fontSize: 12.5, fontWeight: FontWeight.w700, color: color), + ), + ], + ), + const SizedBox(height: 3), + Padding( + padding: const EdgeInsets.only(left: 18), + child: Text( + desc, + style: GoogleFonts.inter( + fontSize: 11.5, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ), + ], + ), + ); + } + + void _showSubscaleInfoModal( + BuildContext context, + String label, + int score, + String severityIndo, + String scaleType, + ) { + final scoreColor = _getScoreColor(severityIndo); + String definition = ''; + String tip = ''; + + if (scaleType == 'depression') { + definition = + 'Subskala Depresi mengukur tingkat suasana hati disforik, hilangnya motivasi/inisiatif, anhedonia (kesulitan merasakan hal positif), dan perasaan ketidakberhargaan.'; + tip = + 'Coba lakukan aktivitas kecil yang kamu sukai, luapkan pikiran melalui percakapan bersama EVA, atau jalan santai menghirup udara segar.'; + } else if (scaleType == 'anxiety') { + definition = + 'Subskala Kecemasan mengukur stimulasi otonomik (otot tegang, detak jantung kencang), cemas situasional, dan ketakutan akan kehilangan kendali.'; + tip = + 'Lakukan teknik pernapasan 4-7-8 atau relaksasi otot bertahap untuk menurunkan stimulasi fisik berlebih.'; + } else { + definition = + 'Subskala Stres mengukur ketegangan syaraf non-spesifik, reaksi berlebihan pada masalah harian, kegelisahan, dan ketidakmampuan untuk bersantai.'; + tip = + 'Ambil jeda istirahat dari pekerjaan/tugas, hindari multitasking berlebih, dan nikmati minuman hangat.'; + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: scoreColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: scoreColor, + ), + ), + ), + const Spacer(), + Text( + '$score / 42 Poin', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Text( + 'Tingkat Keparahan: ', + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textSecondary), + ), + Text( + severityIndo, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: scoreColor, + ), + ), + ], + ), + const SizedBox(height: 14), + const Divider(height: 1, color: Color(0xFFEADBFF)), + const SizedBox(height: 14), + Text( + 'PENJELASAN INDIKATOR:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + Text( + definition, + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textPrimary, height: 1.45), + ), + const SizedBox(height: 14), + Text( + 'REKOMENDASI MANDIRI:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Text( + tip, + style: GoogleFonts.inter(fontSize: 12.5, color: AppColors.textPrimary, height: 1.4), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Tutup'), + ), + ), + ], + ), + ), + ); + } + + void _showPsychologistReferralModal( + BuildContext context, + String label, + int score, + String scaleType, + ) { + String detailExplanation = ''; + + if (scaleType == 'depression') { + detailExplanation = + 'Sistem mencatat tingkat Depresi pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini dicirikan oleh anhedonia total (kehilangan kemampuan merasakan hal positif), perasaan tidak berharga mendalam, serta keletihan fisik dan emosional yang signifikan.\n\n' + '⚠️ Catatan Penting: Eva AI adalah pendamping mandiri dan bukan pengganti diagnosis atau terapi medis klinis. Kamu sangat disarankan untuk berbicara langsung dengan Psikolog Klinis atau Psikiater.'; + } else if (scaleType == 'anxiety') { + detailExplanation = + 'Sistem mencatat tingkat Kecemasan pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini ditandai dengan serangan panik intens, detak jantung kencang tanpa alasan fisik, guncangan rasa takut akut, atau kekhawatiran melumpuhkan harian.\n\n' + '⚠️ Catatan Penting: Gejala kecemasan akut membutuhkan penanganan profesional medis. Sangat disarankan untuk segera menemui Psikolog Klinis atau Psikiater terdekat.'; + } else { + detailExplanation = + 'Sistem mencatat tingkat Stres pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini mengindikasikan burnout ekstrem, ketegangan saraf kronis, kelelahan energi berat, serta keputusasaan dalam menangani tekanan harian.\n\n' + '⚠️ Catatan Penting: Beban stres kronis dapat berdampak pada kesehatan fisik. Disarankan untuk mengambil jeda penuh dan melakukan sesi konseling bersama psikolog profesional.'; + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFEBEE), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Color(0xFFD32F2F), size: 28), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SANGAT BERAT (EXTREMELY SEVERE)', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFFD32F2F), + letterSpacing: 0.5, + ), + ), + Text( + 'Indikasi $label: $score / 42 Poin', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: const Color(0xFFB71C1C), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + 'PENJELASAN KLINIS & REKOMENDASI:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Text( + detailExplanation, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.5, + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(ctx); + _openWhatsappEmergency(context); + }, + icon: const Icon(Icons.chat_outlined, size: 20), + label: Text( + 'Konsultasi via WhatsApp Psikolog / Krisis', + style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF25D366), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text( + 'Tutup Penjelasan', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ), + ], + ), + ), + ); + } + + + static const String _whatsappEmergencyUrl = + 'https://api.whatsapp.com/send/?phone=6281380073120&text=halo%20kak%2C%20saya%20ingin%20bercerita%20mengenai...&type=phone_number&app_absent=0'; + + Future _openWhatsappEmergency(BuildContext context) async { + final uri = Uri.parse(_whatsappEmergencyUrl); + try { + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched) { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } + } catch (_) { + try { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } catch (_) { + if (context.mounted) { + AppSnackBar.showError( + context, + 'Tidak dapat membuka WhatsApp Konseling Krisis', + ); + } + } + } + } + +} diff --git a/apps/eva_mobile/lib/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart new file mode 100644 index 0000000..8632452 --- /dev/null +++ b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart @@ -0,0 +1,70 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +/// 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/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..c02bf0d --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/data/datasources/mock_recommendation_datasource.dart @@ -0,0 +1,103 @@ +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> getTodayRecommendation() async { + await Future.delayed(const Duration(milliseconds: 200)); + return { + 'conditionLabel': 'Fokus: Perawatan Diri & Relaksasi', + 'conditionReason': 'Tingkat stres stabil. Pertahankan rutinitas meditasi.', + 'severityLevel': 'Normal', + 'isCrisis': false, + 'todayActivity': { + 'id': 'c1', + 'title': 'Latihan Pernapasan 4-7-8', + 'category': 'Mindfulness', + 'targetCondition': 'stress', + 'duration': '3-5 Menit', + 'difficulty': 'Pemula', + 'description': 'Tarik napas selama 4 hitungan, tahan 7 hitungan, buang perlahan selama 8 hitungan untuk menenangkan sistem saraf parasimpatis.', + }, + 'allRecommendations': [ + { + 'id': 'c1', + 'title': 'Latihan Pernapasan 4-7-8', + 'category': 'Mindfulness', + 'targetCondition': 'stress', + 'duration': '3-5 Menit', + 'difficulty': 'Pemula', + 'description': 'Tarik napas selama 4 hitungan, tahan 7 hitungan, buang perlahan selama 8 hitungan.', + }, + { + 'id': 'c2', + 'title': 'Jurnal Rasa Syukur 3 Poin', + 'category': 'Refleksi', + 'targetCondition': 'general', + 'duration': '5 Menit', + 'difficulty': 'Mudah', + 'description': 'Tuliskan 3 hal kecil yang kamu syukuri hari ini.', + }, + { + 'id': 'c3', + 'title': 'Peregangan Leher & Bahu', + 'category': 'Fisik', + 'targetCondition': 'stress', + 'duration': '3 Menit', + 'difficulty': 'Mudah', + 'description': 'Gerakkan leher ke kiri, kanan, atas, dan bawah secara perlahan.', + }, + ], + }; + } + + @override + Future 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..95fad65 --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/data/datasources/recommendation_datasource.dart @@ -0,0 +1,7 @@ +import '../models/recommendation_model.dart'; + +abstract class RecommendationDataSource { + Future> getRecommendations(); + Future> getTodayRecommendation(); + 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..ac83728 --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/data/datasources/remote_recommendation_datasource.dart @@ -0,0 +1,30 @@ +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> getTodayRecommendation() async { + final response = await _apiClient.get('/recommendations/today'); + if (response is Map) { + return response; + } + return {}; + } + + @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/fallback_recommendation_repository.dart b/apps/eva_mobile/lib/features/recommendations/data/repositories/fallback_recommendation_repository.dart new file mode 100644 index 0000000..6917272 --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/data/repositories/fallback_recommendation_repository.dart @@ -0,0 +1,41 @@ +import '../../domain/repositories/recommendation_repository.dart'; +import '../../domain/entities/recommendation_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackRecommendationRepository implements RecommendationRepository { + final RecommendationRepository _remote; + final RecommendationRepository _mock; + + FallbackRecommendationRepository({ + required RecommendationRepository remote, + required RecommendationRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future> getRecommendations() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getRecommendations(), + mockCall: () => _mock.getRecommendations(), + operationName: 'getRecommendations', + ); + } + + @override + Future> getTodayRecommendation() { + return FallbackExecutor.execute( + remoteCall: () => _remote.getTodayRecommendation(), + mockCall: () => _mock.getTodayRecommendation(), + operationName: 'getTodayRecommendation', + ); + } + + @override + Future markAsCompleted(String id) { + return FallbackExecutor.execute( + remoteCall: () => _remote.markAsCompleted(id), + mockCall: () => _mock.markAsCompleted(id), + operationName: 'markAsCompleted($id)', + ); + } +} diff --git a/apps/eva_mobile/lib/features/recommendations/data/repositories/recommendation_repository_impl.dart b/apps/eva_mobile/lib/features/recommendations/data/repositories/recommendation_repository_impl.dart new file mode 100644 index 0000000..3acbcae --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/data/repositories/recommendation_repository_impl.dart @@ -0,0 +1,18 @@ +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> getTodayRecommendation() => _dataSource.getTodayRecommendation(); + + @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..f1dd6aa --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/domain/repositories/recommendation_repository.dart @@ -0,0 +1,7 @@ +import '../entities/recommendation_entity.dart'; + +abstract class RecommendationRepository { + Future> getRecommendations(); + Future> getTodayRecommendation(); + 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..9624c98 --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/presentation/providers/recommendation_provider.dart @@ -0,0 +1,26 @@ +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'; +import '../../data/repositories/fallback_recommendation_repository.dart'; + +final recommendationRepositoryProvider = Provider((ref) { + final mockRepo = RecommendationRepositoryImpl(MockRecommendationDataSource()); + final remoteRepo = RecommendationRepositoryImpl(RemoteRecommendationDataSource()); + + if (AppConfig.useMockData) { + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackRecommendationRepository(remote: remoteRepo, mock: mockRepo); + } else { + return remoteRepo; + } +}); + +final recommendationsProvider = FutureProvider>((ref) async { + final repo = ref.watch(recommendationRepositoryProvider); + return repo.getRecommendations(); +}); diff --git a/apps/eva_mobile/lib/features/recommendations/presentation/screens/recommendation_screen.dart b/apps/eva_mobile/lib/features/recommendations/presentation/screens/recommendation_screen.dart new file mode 100644 index 0000000..a32604f --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/presentation/screens/recommendation_screen.dart @@ -0,0 +1,1229 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/recommendations/presentation/providers/recommendation_provider.dart'; +import 'package:eva_mobile/features/recommendations/presentation/widgets/guidance_modal_content.dart'; +import 'package:eva_mobile/features/recommendations/presentation/widgets/recommendation_skeleton_loader.dart'; +import 'package:eva_mobile/services/daily_progress_local_service.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class RecommendationScreen extends ConsumerStatefulWidget { + const RecommendationScreen({super.key}); + + @override + ConsumerState createState() => _RecommendationScreenState(); +} + +class _RecommendationScreenState extends ConsumerState { + 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(); + + setState(() => _isLoading = true); + try { + final repo = ref.read(recommendationRepositoryProvider); + final data = await repo.getTodayRecommendation(); + + if (!mounted) return; + + if (data.isNotEmpty) { + final rawToday = data['todayActivity'] as Map?; + 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) { + AppSnackBar.showPrimary( + context, + 'Kamu sudah menyelesaikan latihan ini hari ini! Kerja bagus, istirahatlah sejenak dan lanjutkan kembali besok 🌿', + icon: Icons.info_outline, + ); + } + return; + } + + setState(() { + if (idx != -1) { + _recommendations[idx]['isCompleted'] = true; + } + if (_todayActivity != null && _todayActivity!['id'] == id) { + _todayActivity!['isCompleted'] = true; + } + }); + + await DailyProgressLocalService.recordActivityCompletion(id, true); + + if (mounted) { + AppSnackBar.showSuccess( + context, + 'Bagus sekali! Satu langkah kecil untuk ketenanganmu telah selesai.', + icon: Icons.check_circle, + ); + } + + try { + await ref.read(recommendationRepositoryProvider).markAsCompleted(id); + } catch (_) {} + } + + IconData _getIconForActivity(String? iconName, String? category) { + if (iconName != null) { + switch (iconName.toLowerCase()) { + case 'air': + return Icons.air; + case 'spa': + return Icons.spa; + case 'psychology': + return Icons.psychology; + case 'edit_note': + return Icons.edit_note; + case 'directions_walk': + return Icons.directions_walk; + case 'emergency': + return Icons.emergency; + case 'task_alt': + return Icons.task_alt; + case 'phonelink_off': + return Icons.phonelink_off; + case 'self_improvement': + return Icons.self_improvement; + } + } + final cat = (category ?? '').toLowerCase(); + if (cat.contains('mindful') || cat.contains('napas')) return Icons.air; + if (cat.contains('refleksi') || cat.contains('self-care') || cat.contains('syukur')) return Icons.edit_note; + if (cat.contains('cbt') || cat.contains('problem') || cat.contains('kognitif')) return Icons.psychology; + if (cat.contains('somatis') || cat.contains('fisik') || cat.contains('aktivasi') || cat.contains('jalan')) return Icons.directions_walk; + if (cat.contains('krisis') || cat.contains('darurat')) return Icons.emergency; + return Icons.self_improvement; + } + + Color _getCategoryColor(String? category) { + final cat = (category ?? '').toLowerCase(); + if (cat.contains('mindful') || cat.contains('napas')) return const Color(0xFF6366F1); + if (cat.contains('refleksi') || cat.contains('self-care') || cat.contains('syukur')) return const Color(0xFF8B5CF6); + if (cat.contains('cbt') || cat.contains('problem') || cat.contains('kognitif')) return const Color(0xFF0EA5E9); + if (cat.contains('somatis') || cat.contains('fisik') || cat.contains('aktivasi') || cat.contains('jalan')) return const Color(0xFF10B981); + if (cat.contains('krisis') || cat.contains('darurat')) return const Color(0xFFEF4444); + return AppColors.primary; + } + + Color _getSeverityColor(String severity) { + switch (severity.toLowerCase()) { + case 'berat': + case 'sangat berat': + case 'krisis / darurat': + return const Color(0xFFEF4444); + case 'sedang': + return const Color(0xFFF59E0B); + case 'ringan': + return const Color(0xFF10B981); + default: + return const Color(0xFF6366F1); + } + } + + bool _matchesCategory(String? category, String selected) { + if (selected == 'Semua') return true; + final cat = (category ?? '').toLowerCase(); + switch (selected) { + case 'Mindfulness': + return cat.contains('mindful') || cat.contains('napas') || cat.contains('breath'); + case 'CBT': + return cat.contains('cbt') || cat.contains('kognitif') || cat.contains('problem') || cat.contains('unhooking'); + case 'Refleksi': + return cat.contains('refleksi') || cat.contains('syukur') || cat.contains('self-care') || cat.contains('catatan'); + case 'Fisik & Gerak': + return cat.contains('fisik') || cat.contains('gerak') || cat.contains('somatis') || cat.contains('aktivasi') || cat.contains('jalan'); + default: + return cat.contains(selected.toLowerCase()); + } + } + + int _getCategoryCount(String category) { + if (category == 'Semua') return _recommendations.length; + return _recommendations.where((r) => _matchesCategory(r['category']?.toString(), category)).length; + } + + @override + Widget build(BuildContext context) { + final filteredRecommendations = _selectedCategory == 'Semua' + ? _recommendations + : _recommendations + .where((r) => _matchesCategory(r['category']?.toString(), _selectedCategory)) + .toList(); + + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF7F9FD), + Color(0xFFEFF3FD), + Color(0xFFF7F8FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: Column( + children: [ + StaggeredEntrance( + index: 0, + child: _buildTopBar(), + ), + Expanded( + child: _isLoading + ? const RecommendationSkeletonLoader() + : RefreshIndicator( + onRefresh: _fetchRecommendations, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20.0, 8.0, 20.0, 80.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Crisis Emergency Banner (if applicable) + if (_isCrisis) ...[ + _buildCrisisBanner(), + const SizedBox(height: 18), + ], + + // 2. Clinically Tailored Condition Header Card + StaggeredEntrance( + index: 1, + child: _buildConditionContextCard(), + ), + const SizedBox(height: 20), + + // 3. Featured Activity of the Day + if (_todayActivity != null) ...[ + StaggeredEntrance( + index: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Aktivitas Utama Hari Ini', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Langkah kecil paling relevan untuk menenangkan pikiranmu saat ini.', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 10), + _buildFeaturedTodayCard(_todayActivity!), + ], + ), + ), + const SizedBox(height: 24), + ], + + // 4. Category Filter Chips + StaggeredEntrance( + index: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pilihan Latihan Ketenangan', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Pilih teknik yang paling nyaman dan sesuai kebutuhanmu.', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + _buildCategorySelector(), + ], + ), + ), + const SizedBox(height: 14), + + // 5. List of Recommended Activities + if (filteredRecommendations.isEmpty) + _buildEmptyState() + else + StaggeredEntrance( + index: 4, + child: Column( + children: [ + for (final rec in filteredRecommendations) + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: _buildActivityCard(rec), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTopBar() { + final canPop = Navigator.canPop(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0), + child: Row( + children: [ + if (canPop) ...[ + const AppBackButton(), + const SizedBox(width: 12), + ] else ...[ + Image.asset( + 'assets/images/eva_logo.png', + width: 30, + height: 30, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => const Icon( + Icons.nightlight_round, + size: 26, + color: AppColors.primary, + ), + ), + const SizedBox(width: 8), + ], + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Panduan Ketenangan', + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Latihan & Pemulihan Mandiri', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + const Spacer(), + IconButton( + tooltip: 'Perbarui Panduan', + icon: const Icon(Icons.refresh, size: 22), + color: AppColors.primary, + onPressed: _fetchRecommendations, + ), + ], + ), + ); + } + + Widget _buildCrisisBanner() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFFECACA), width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.red.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Color(0xFFFEE2E2), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.favorite, + color: Color(0xFFDC2626), + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Kamu Berharga & Tidak Sendirian', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: const Color(0xFF991B1B), + ), + ), + Text( + 'Prioritas Utama: Keselamatan & Bantuan Nyata', + style: GoogleFonts.inter( + fontSize: 11, + color: const Color(0xFFB91C1C), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + _crisisGuidance, + style: GoogleFonts.inter( + fontSize: 12, + height: 1.4, + color: const Color(0xFF7F1D1D), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + Navigator.pushNamed(context, '/support'); + }, + icon: const Icon(Icons.support_agent, size: 18, color: Colors.white), + label: Text( + 'Pusat Bantuan & Hotline', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFDC2626), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildConditionContextCard() { + final sevColor = _getSeverityColor(_severityLevel); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5358CB).withValues(alpha: 0.06), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.insights_rounded, + size: 13, + color: AppColors.primary, + ), + const SizedBox(width: 5), + Flexible( + child: Text( + _conditionLabel.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 0.4, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: sevColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: sevColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + 'Level: $_severityLevel', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: sevColor, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + _conditionReason.isNotEmpty + ? _conditionReason + : 'Panduan ini dipilih agar kamu tahu apa yang harus dilakukan tanpa bingung, langkah demi langkah.', + style: GoogleFonts.inter( + fontSize: 12, + height: 1.45, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildFeaturedTodayCard(Map 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); + }, + ); + }, + ); + } +} diff --git a/apps/eva_mobile/lib/features/recommendations/presentation/widgets/guidance_modal_content.dart b/apps/eva_mobile/lib/features/recommendations/presentation/widgets/guidance_modal_content.dart new file mode 100644 index 0000000..1537763 --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/presentation/widgets/guidance_modal_content.dart @@ -0,0 +1,413 @@ +import "dart:async"; +import "package:flutter/material.dart"; +import "package:google_fonts/google_fonts.dart"; + +import "../../../../theme/app_colors.dart"; + +class GuidanceModalContent extends StatefulWidget { + final String actId; + final String title; + final String category; + final String duration; + final String difficulty; + final String description; + final String rationale; + final List instructions; + final bool isBreathingActivity; + final bool isCompleted; + final VoidCallback onComplete; + + const GuidanceModalContent({ + super.key, + required this.actId, + required this.title, + required this.category, + required this.duration, + required this.difficulty, + required this.description, + required this.rationale, + required this.instructions, + required this.isBreathingActivity, + required this.isCompleted, + required this.onComplete, + }); + + @override + State createState() => GuidanceModalContentState(); +} + +class GuidanceModalContentState extends State { + bool _isBreathPacerActive = false; + int _breathTimerSec = 0; + String _breathPhase = 'Tarik Napas'; + Timer? _breathTimer; + + @override + void dispose() { + _breathTimer?.cancel(); + super.dispose(); + } + + void _toggleBreathPacer() { + if (_isBreathPacerActive) { + _breathTimer?.cancel(); + setState(() { + _isBreathPacerActive = false; + _breathTimerSec = 0; + _breathPhase = 'Selesai'; + }); + } else { + setState(() { + _isBreathPacerActive = true; + _breathTimerSec = 0; + _breathPhase = 'Tarik Napas (4 dtk)'; + }); + + _breathTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return; + setState(() { + _breathTimerSec++; + final cycle = _breathTimerSec % 19; + if (cycle < 4) { + _breathPhase = 'Tarik Napas Perlahan (4s)'; + } else if (cycle < 11) { + _breathPhase = 'Tahan Napas di Dada (7s)'; + } else { + _breathPhase = 'Hembuskan Napas Rileks (8s)'; + } + }); + }); + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: const EdgeInsets.fromLTRB(22, 14, 22, 24), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.88, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + + // Header tags & close + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + widget.category.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 8), + Text( + '• ${widget.duration} • ${widget.difficulty}', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 8), + + // Title + Text( + widget.title, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + widget.description, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 14), + + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Rationale Card + if (widget.rationale.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFDDD6FE)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.lightbulb_outline, + size: 18, + color: Color(0xFF7C3AED), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Kenapa Latihan Ini Membantu?', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF5B21B6), + ), + ), + const SizedBox(height: 3), + Text( + widget.rationale, + style: GoogleFonts.inter( + fontSize: 11.5, + height: 1.4, + color: const Color(0xFF6D28D9), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // 2. Interactive Breath Pacer (if relevant) + if (widget.isBreathingActivity) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFFEEF2FF), + const Color(0xFFE0E7FF), + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.air, size: 16, color: Color(0xFF4F46E5)), + const SizedBox(width: 6), + Text( + 'Pemandu Irama Napas', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF3730A3), + ), + ), + ], + ), + GestureDetector( + onTap: _toggleBreathPacer, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: _isBreathPacerActive + ? const Color(0xFFEF4444) + : const Color(0xFF4F46E5), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _isBreathPacerActive ? 'Hentikan' : 'Mulai Pemandu', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + if (_isBreathPacerActive) ...[ + const SizedBox(height: 14), + AnimatedContainer( + duration: const Duration(milliseconds: 700), + width: _breathPhase.contains('Tarik') ? 80 : 65, + height: _breathPhase.contains('Tarik') ? 80 : 65, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF6366F1).withValues(alpha: 0.2), + border: Border.all(color: const Color(0xFF4F46E5), width: 2), + ), + child: Center( + child: Text( + '${_breathTimerSec}s', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: const Color(0xFF3730A3), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + _breathPhase, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF4338CA), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + ], + + // 3. Step-by-step instructions + Text( + 'Langkah-Langkah yang Perlu Dilakukan:', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.instructions.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, idx) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.primaryContainer, + shape: BoxShape.circle, + ), + child: Text( + '${idx + 1}', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.instructions[idx], + style: GoogleFonts.inter( + fontSize: 13, + height: 1.4, + color: AppColors.textPrimary, + ), + ), + ), + ], + ); + }, + ), + const SizedBox(height: 20), + ], + ), + ), + ), + + // Completion Button CTA + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: widget.onComplete, + icon: Icon( + widget.isCompleted ? Icons.check_circle : Icons.check, + color: Colors.white, + size: 18, + ), + label: Text( + widget.isCompleted + ? 'Sudah Diselesaikan Hari Ini' + : 'Saya Sudah Melakukan Latihan Ini', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: widget.isCompleted + ? const Color(0xFF10B981) + : AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 0, + ), + ), + ), + ], + ), + ); + } +} + diff --git a/apps/eva_mobile/lib/features/recommendations/presentation/widgets/recommendation_skeleton_loader.dart b/apps/eva_mobile/lib/features/recommendations/presentation/widgets/recommendation_skeleton_loader.dart new file mode 100644 index 0000000..f54010b --- /dev/null +++ b/apps/eva_mobile/lib/features/recommendations/presentation/widgets/recommendation_skeleton_loader.dart @@ -0,0 +1,102 @@ +import "package:flutter/material.dart"; + +import "../../../../widgets/glass_card.dart"; +import "../../../../widgets/skeleton_shimmer.dart"; + +class RecommendationSkeletonLoader extends StatelessWidget { + const RecommendationSkeletonLoader({super.key}); + + @override + Widget build(BuildContext context) { + return SkeletonShimmerHost( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20.0, 8.0, 20.0, 80.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Condition Header Card Skeleton + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const SkeletonCircle(size: 38), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 140, height: 16), + SizedBox(height: 6), + SkeletonLine(width: 90, height: 12), + ], + ), + ], + ), + const SizedBox(height: 16), + const SkeletonLine(width: double.infinity, height: 13), + const SizedBox(height: 8), + const SkeletonLine(width: 220, height: 13), + ], + ), + ), + const SizedBox(height: 20), + + // Featured Activity Skeleton + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonBox(width: 110, height: 22, borderRadius: 8), + SizedBox(height: 14), + SkeletonLine(width: 200, height: 18), + SizedBox(height: 10), + SkeletonLine(width: double.infinity, height: 13), + SizedBox(height: 6), + SkeletonLine(width: 260, height: 13), + SizedBox(height: 16), + SkeletonBox(width: double.infinity, height: 44, borderRadius: 12), + ], + ), + ), + const SizedBox(height: 20), + + // Activity List Skeletons + ...List.generate( + 2, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 14.0), + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const SkeletonCircle(size: 42), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + SkeletonLine(width: 150, height: 15), + SizedBox(height: 8), + SkeletonLine(width: 100, height: 12), + ], + ), + ), + const SkeletonBox(width: 28, height: 28, borderRadius: 8), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/tutorial/data/repositories/tutorial_repository_impl.dart b/apps/eva_mobile/lib/features/tutorial/data/repositories/tutorial_repository_impl.dart new file mode 100644 index 0000000..67a0fea --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/data/repositories/tutorial_repository_impl.dart @@ -0,0 +1,32 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import '../../domain/repositories/tutorial_repository.dart'; + +class TutorialRepositoryImpl implements TutorialRepository { + static const String _keyCompleted = 'eva_tutorial_completed_v1'; + + @override + Future hasCompletedTutorial() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_keyCompleted) ?? false; + } catch (_) { + return false; + } + } + + @override + Future setTutorialCompleted(bool completed) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyCompleted, completed); + } catch (_) {} + } + + @override + Future resetTutorial() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyCompleted); + } catch (_) {} + } +} diff --git a/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart b/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart new file mode 100644 index 0000000..5e0dfb7 --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart @@ -0,0 +1,80 @@ +enum TutorialStep { + voiceSession, + journal, + analytics, + care, +} + +enum SpotlightShape { + circle, + roundedRectangle, +} + +enum PointerDirection { + up, + down, + none, +} + +class TutorialContent { + final String title; + final String description; + final String? actionHint; + final String badgeText; + final SpotlightShape shape; + final PointerDirection pointerDirection; + final bool isInteractiveTarget; + + const TutorialContent({ + required this.title, + required this.description, + this.actionHint, + this.badgeText = 'LUNA AI', + this.shape = SpotlightShape.circle, + this.pointerDirection = PointerDirection.down, + this.isInteractiveTarget = true, + }); + + static const Map stepContents = { + TutorialStep.voiceSession: TutorialContent( + title: 'Cerita ke Luna, yuk!', + description: + 'Tekan tombol tengah untuk mulai berbicara. Kamu bisa mulai saat sudah siap.', + actionHint: 'Ketuk tombol di bawah untuk mencoba', + badgeText: 'REKOMENDASI LUNA', + shape: SpotlightShape.circle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: true, + ), + TutorialStep.journal: TutorialContent( + title: 'Jurnal Refleksi Otomatis', + description: + 'Setiap selesai bercerita, intisari perasaan dan renunganmu otomatis tersusun rapi di sini secara privat.', + actionHint: 'Buka kapan saja untuk membaca catatanmu', + badgeText: 'JURNAL AI', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: false, + ), + TutorialStep.analytics: TutorialContent( + title: 'Tren Emosi & DASS-21', + description: + 'AI merangkum indikator suasana hatimu otomatis. Kamu memiliki kendali penuh untuk menyesuaikan nilainya secara mandiri.', + actionHint: 'Pantau kesehatan emosimu berkala', + badgeText: 'ANALITIK DASS', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: false, + ), + TutorialStep.care: TutorialContent( + title: 'Layanan & Dukungan Sebaya', + description: + 'Jika butuh teman bicara manusia, temukan konseling profesional dan volunteer terpercaya di sini.', + actionHint: 'Bantuan selalu tersedia untukmu', + badgeText: 'LAYANAN CARE', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: false, + ), + }; +} diff --git a/apps/eva_mobile/lib/features/tutorial/domain/repositories/tutorial_repository.dart b/apps/eva_mobile/lib/features/tutorial/domain/repositories/tutorial_repository.dart new file mode 100644 index 0000000..9bb5957 --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/domain/repositories/tutorial_repository.dart @@ -0,0 +1,5 @@ +abstract class TutorialRepository { + Future hasCompletedTutorial(); + Future setTutorialCompleted(bool completed); + Future resetTutorial(); +} diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/providers/tutorial_provider.dart b/apps/eva_mobile/lib/features/tutorial/presentation/providers/tutorial_provider.dart new file mode 100644 index 0000000..fb5979e --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/presentation/providers/tutorial_provider.dart @@ -0,0 +1,142 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../data/repositories/tutorial_repository_impl.dart'; +import '../../domain/entities/tutorial_step.dart'; +import '../../domain/repositories/tutorial_repository.dart'; + +final tutorialRepositoryProvider = Provider((ref) { + return TutorialRepositoryImpl(); +}); + +class TutorialState { + final bool isInitialized; + final bool isActive; + final bool isCompleted; + final TutorialStep? currentStep; + + const TutorialState({ + this.isInitialized = false, + this.isActive = false, + this.isCompleted = false, + this.currentStep, + }); + + TutorialState copyWith({ + bool? isInitialized, + bool? isActive, + bool? isCompleted, + TutorialStep? currentStep, + bool clearStep = false, + }) { + return TutorialState( + isInitialized: isInitialized ?? this.isInitialized, + isActive: isActive ?? this.isActive, + isCompleted: isCompleted ?? this.isCompleted, + currentStep: clearStep ? null : (currentStep ?? this.currentStep), + ); + } +} + +class TutorialNotifier extends StateNotifier { + final TutorialRepository _repository; + + TutorialNotifier(this._repository) : super(const TutorialState()) { + checkInitialStatus(); + } + + /// Always ensures the recommendation/tutorial guidance is active on app open or restart + Future checkInitialStatus() async { + state = const TutorialState( + isInitialized: true, + isCompleted: false, + isActive: true, + currentStep: TutorialStep.voiceSession, + ); + } + + /// Explicitly triggers recommendation/tutorial on screen initialization/restart + void ensureActiveOnLaunch() { + state = const TutorialState( + isInitialized: true, + isCompleted: false, + isActive: true, + currentStep: TutorialStep.voiceSession, + ); + } + + Future startTutorial() async { + state = state.copyWith( + isActive: true, + isCompleted: false, + currentStep: TutorialStep.voiceSession, + ); + } + + void nextStep() { + if (state.currentStep == null) return; + + final steps = TutorialStep.values; + final currentIndex = steps.indexOf(state.currentStep!); + + if (currentIndex + 1 < steps.length) { + state = state.copyWith( + currentStep: steps[currentIndex + 1], + ); + } else { + completeTutorial(); + } + } + + Future onTargetTapped(TutorialStep step) async { + if (state.currentStep == step) { + if (step == TutorialStep.voiceSession) { + // Voice session launched; pause overlay so it does not interfere with voice screen + state = state.copyWith( + isActive: false, + currentStep: TutorialStep.journal, + ); + } else { + nextStep(); + } + } + } + + void resumeAtStep(TutorialStep step) { + state = state.copyWith( + isActive: true, + currentStep: step, + ); + } + + /// Skips tutorial for the current session. Will show again on app restart as requested. + Future skipTutorial() async { + state = state.copyWith( + isActive: false, + isCompleted: true, + clearStep: true, + ); + } + + Future completeTutorial() async { + state = state.copyWith( + isActive: false, + isCompleted: true, + clearStep: true, + ); + } + + Future resetTutorial() async { + await _repository.resetTutorial(); + state = const TutorialState( + isInitialized: true, + isActive: true, + isCompleted: false, + currentStep: TutorialStep.voiceSession, + ); + } +} + +final tutorialNotifierProvider = + StateNotifierProvider((ref) { + final repository = ref.watch(tutorialRepositoryProvider); + return TutorialNotifier(repository); +}); diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart new file mode 100644 index 0000000..320a4ff --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/tutorial_step.dart'; + +/// A branded, calming guidance card styled specifically for Luna/Eva AI. +/// Combines typography hierarchy, subtle brand identity accents, and an optional +/// subtle downward/upward pointer connected seamlessly to the card. +class BrandedGuidanceCard extends StatelessWidget { + final String title; + final String description; + final String? actionHint; + final String badgeText; + final PointerDirection pointerDirection; + final double pointerHorizontalOffset; + final bool isInteractiveTarget; + final VoidCallback onSkip; + final VoidCallback? onNext; + + const BrandedGuidanceCard({ + super.key, + required this.title, + required this.description, + this.actionHint, + this.badgeText = 'LUNA AI', + this.pointerDirection = PointerDirection.down, + this.pointerHorizontalOffset = 0.0, + this.isInteractiveTarget = true, + required this.onSkip, + this.onNext, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (pointerDirection == PointerDirection.up) ...[ + _buildPointer(isPointingUp: true), + ], + + // Main Card Surface + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: AppColors.primaryContainer.withValues(alpha: 0.9), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.12), + blurRadius: 30, + spreadRadius: 0, + offset: const Offset(0, 10), + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + spreadRadius: 0, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.fromLTRB(20, 16, 20, 18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row: Brand Badge + Skip Action + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Subtle Brand Chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4.5), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/images/luna_logo_square.png', + width: 14, + height: 14, + errorBuilder: (context, error, stackTrace) => const Icon( + Icons.auto_awesome, + size: 13, + color: AppColors.primary, + ), + ), + const SizedBox(width: 5), + Text( + badgeText, + style: GoogleFonts.inter( + fontSize: 10.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: AppColors.primary, + ), + ), + ], + ), + ), + + // Subtle "Lewati" Button + GestureDetector( + onTap: onSkip, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Text( + 'Lewati', + style: GoogleFonts.inter( + fontSize: 12.5, + fontWeight: FontWeight.w500, + color: AppColors.textLight, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Title + Text( + title, + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + height: 1.3, + ), + ), + const SizedBox(height: 6), + + // Description + Text( + description, + style: GoogleFonts.inter( + fontSize: 13.5, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + height: 1.45, + ), + ), + + // Action Cue Banner (if available) + if (actionHint != null && actionHint!.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8.5), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.primaryContainer.withValues(alpha: 0.5), + ), + ), + child: Row( + children: [ + const Icon( + Icons.touch_app_outlined, + size: 15, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + actionHint!, + style: GoogleFonts.inter( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ), + ], + ), + ), + ], + + // For non-interactive target (informational card), offer a primary "Lanjut" button + if (!isInteractiveTarget && onNext != null) ...[ + const SizedBox(height: 14), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton( + onPressed: onNext, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + 'Lanjut', + style: GoogleFonts.inter( + fontSize: 12.5, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ], + ), + ), + + if (pointerDirection == PointerDirection.down) ...[ + _buildPointer(isPointingUp: false), + ], + ], + ); + } + + Widget _buildPointer({required bool isPointingUp}) { + return Transform.translate( + offset: Offset(pointerHorizontalOffset, isPointingUp ? 1.0 : -1.0), + child: CustomPaint( + size: const Size(18, 9), + painter: _SubtlePointerPainter( + isPointingUp: isPointingUp, + fillColor: Colors.white, + borderColor: AppColors.primaryContainer.withValues(alpha: 0.9), + ), + ), + ); + } +} + +class _SubtlePointerPainter extends CustomPainter { + final bool isPointingUp; + final Color fillColor; + final Color borderColor; + + _SubtlePointerPainter({ + required this.isPointingUp, + required this.fillColor, + required this.borderColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final path = Path(); + final borderPath = Path(); + + if (isPointingUp) { + // Triangle pointing upward: bottom-left to top-center to bottom-right + path.moveTo(0, size.height); + path.lineTo(size.width / 2, 0); + path.lineTo(size.width, size.height); + path.close(); + + borderPath.moveTo(0, size.height); + borderPath.lineTo(size.width / 2, 0); + borderPath.lineTo(size.width, size.height); + } else { + // Triangle pointing downward: top-left to bottom-center to top-right + path.moveTo(0, 0); + path.lineTo(size.width / 2, size.height); + path.lineTo(size.width, 0); + path.close(); + + borderPath.moveTo(0, 0); + borderPath.lineTo(size.width / 2, size.height); + borderPath.lineTo(size.width, 0); + } + + final fillPaint = Paint() + ..color = fillColor + ..style = PaintingStyle.fill; + canvas.drawPath(path, fillPaint); + + final borderPaint = Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + canvas.drawPath(borderPath, borderPaint); + } + + @override + bool shouldRepaint(covariant _SubtlePointerPainter oldDelegate) { + return oldDelegate.isPointingUp != isPointingUp || + oldDelegate.fillColor != fillColor || + oldDelegate.borderColor != borderColor; + } +} diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart new file mode 100644 index 0000000..70181a3 --- /dev/null +++ b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart @@ -0,0 +1,349 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/tutorial_step.dart'; +import 'branded_guidance_card.dart'; + +/// An interactive, branded spotlight overlay for Luna/Eva AI. +/// Renders a dimmed, calming backdrop with an exact cutout around the target widget, +/// soft ambient halo ring, and a dynamically positioned [BrandedGuidanceCard]. +/// +/// Uses standard declarative Flutter layers: +/// 1. Fullscreen modal backdrop capturing taps outside the target. +/// 2. Direct tap target over the cutout hole invoking [onTargetTap]. +/// 3. Dynamically positioned guidance card with action cues and skip option. +class InteractiveSpotlightOverlay extends StatefulWidget { + final GlobalKey? targetKey; + final Rect? targetRect; + final TutorialContent content; + final VoidCallback onSkip; + final VoidCallback? onTargetTap; + final VoidCallback? onNext; + final double cutoutPadding; + + const InteractiveSpotlightOverlay({ + super.key, + this.targetKey, + this.targetRect, + required this.content, + required this.onSkip, + this.onTargetTap, + this.onNext, + this.cutoutPadding = 8.0, + }); + + @override + State createState() => + _InteractiveSpotlightOverlayState(); +} + +class _InteractiveSpotlightOverlayState extends State + with TickerProviderStateMixin { + late final AnimationController _fadeController; + late final AnimationController _haloController; + Rect? _measuredTargetRect; + + @override + void initState() { + super.initState(); + _fadeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + ); + + _haloController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2400), + )..repeat(reverse: true); + + _scheduleMeasurement(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final disableAnimations = + MediaQuery.maybeOf(context)?.disableAnimations ?? false; + if (disableAnimations) { + _fadeController.value = 1.0; + } else { + _fadeController.forward(); + } + } + }); + } + + void _scheduleMeasurement() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _updateTargetMeasurement(); + // Retry in subsequent frame if target is still mounting or laying out + if (_measuredTargetRect == null && widget.targetKey != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _updateTargetMeasurement(); + }); + } + }); + } + + @override + void didUpdateWidget(covariant InteractiveSpotlightOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.targetKey != widget.targetKey || + oldWidget.targetRect != widget.targetRect) { + _scheduleMeasurement(); + } + } + + void _updateTargetMeasurement() { + if (!mounted) return; + + if (widget.targetRect != null) { + if (_measuredTargetRect != widget.targetRect) { + setState(() { + _measuredTargetRect = widget.targetRect; + }); + } + return; + } + + if (widget.targetKey == null) return; + + final targetContext = widget.targetKey!.currentContext; + if (targetContext == null) return; + + final renderBox = targetContext.findRenderObject() as RenderBox?; + if (renderBox != null && renderBox.attached) { + final overlayBox = context.findRenderObject() as RenderBox?; + final Offset offset; + if (overlayBox != null && overlayBox.attached) { + offset = overlayBox.globalToLocal(renderBox.localToGlobal(Offset.zero)); + } else { + offset = renderBox.localToGlobal(Offset.zero); + } + + final rect = offset & renderBox.size; + if (_measuredTargetRect != rect) { + setState(() { + _measuredTargetRect = rect; + }); + } + } + } + + @override + void dispose() { + _fadeController.dispose(); + _haloController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final screenSize = mediaQuery.size; + + // Use measured rect if available, or compute fallback for center button on frame 0 + final effectiveTargetRect = _measuredTargetRect ?? + widget.targetRect ?? + (widget.content.shape == SpotlightShape.circle + ? Rect.fromCircle( + center: Offset(screenSize.width / 2, screenSize.height - 59), + radius: 29, + ) + : null); + + return AnimatedBuilder( + animation: Listenable.merge([_fadeController, _haloController]), + builder: (context, _) { + final opacity = _fadeController.value; + final haloValue = _haloController.value; + + if (opacity <= 0.0) return const SizedBox.shrink(); + + return Opacity( + opacity: opacity, + child: Stack( + fit: StackFit.expand, + children: [ + // 1. Fullscreen Dimmed Backdrop (Absorbs taps outside target hole) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + // Absorbs taps on dimmed area so background buttons cannot be accidentally clicked + }, + child: CustomPaint( + size: Size.infinite, + painter: _SpotlightCutoutPainter( + targetRect: effectiveTargetRect, + shape: widget.content.shape, + padding: widget.cutoutPadding, + haloOpacity: 0.35 + (0.30 * haloValue), + ), + ), + ), + ), + + // 2. Direct Tap Target for the Highlighted Control (Guarantees responsive tapping) + if (effectiveTargetRect != null) + Positioned.fromRect( + rect: effectiveTargetRect.inflate(widget.cutoutPadding + 8.0), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + widget.onTargetTap?.call(); + }, + child: Container( + color: Colors.transparent, + ), + ), + ), + + // 3. Dynamically Positioned Guidance Card + if (effectiveTargetRect != null) + _buildPositionedCard(effectiveTargetRect), + ], + ), + ); + }, + ); + } + + Widget _buildPositionedCard(Rect targetRect) { + final mediaQuery = MediaQuery.of(context); + final screenSize = mediaQuery.size; + final safePadding = mediaQuery.padding; + + const double cardMaxWidth = 360.0; + final double cardWidth = math.min(screenSize.width - 32.0, cardMaxWidth); + + // Calculate vertical placement + final double spaceAbove = targetRect.top - safePadding.top; + final double spaceBelow = + screenSize.height - safePadding.bottom - targetRect.bottom; + + // Prefer placing card on the side with more space + final bool placeAbove = spaceAbove >= spaceBelow || spaceAbove >= 220; + + // Horizontal placement: Center card relative to target, clamped to screen safe bounds + final double targetCenterX = targetRect.center.dx; + double cardLeft = targetCenterX - (cardWidth / 2); + final double minLeft = safePadding.left + 16.0; + final double maxLeft = screenSize.width - safePadding.right - 16.0 - cardWidth; + cardLeft = cardLeft.clamp(minLeft, math.max(minLeft, maxLeft)); + + // Pointer offset relative to card center + final double cardCenterX = cardLeft + (cardWidth / 2); + final double pointerOffset = (targetCenterX - cardCenterX).clamp( + -(cardWidth / 2) + 28.0, + (cardWidth / 2) - 28.0, + ); + + const double gapToCutout = 10.0; + + return Positioned( + left: cardLeft, + width: cardWidth, + top: placeAbove ? null : targetRect.bottom + widget.cutoutPadding + gapToCutout, + bottom: placeAbove + ? (screenSize.height - targetRect.top) + widget.cutoutPadding + gapToCutout + : null, + child: SafeArea( + top: !placeAbove, + bottom: placeAbove, + minimum: const EdgeInsets.symmetric(horizontal: 4), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: BrandedGuidanceCard( + title: widget.content.title, + description: widget.content.description, + actionHint: widget.content.actionHint, + badgeText: widget.content.badgeText, + pointerDirection: + placeAbove ? PointerDirection.down : PointerDirection.up, + pointerHorizontalOffset: pointerOffset, + isInteractiveTarget: widget.content.isInteractiveTarget, + onSkip: widget.onSkip, + onNext: widget.onNext, + ), + ), + ), + ); + } +} + +/// Custom painter for the dimmed backdrop, cutout, and ambient halo glow ring. +class _SpotlightCutoutPainter extends CustomPainter { + final Rect? targetRect; + final SpotlightShape shape; + final double padding; + final double haloOpacity; + + _SpotlightCutoutPainter({ + required this.targetRect, + required this.shape, + required this.padding, + required this.haloOpacity, + }); + + @override + void paint(Canvas canvas, Size size) { + final screenPath = Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); + + if (targetRect == null) { + // If no target, simply paint dim overlay + canvas.drawPath( + screenPath, + Paint()..color = const Color(0xFF0F1123).withValues(alpha: 0.72), + ); + return; + } + + final Path holePath = Path(); + if (shape == SpotlightShape.circle) { + final center = targetRect!.center; + final radius = + (math.max(targetRect!.width, targetRect!.height) / 2) + padding; + holePath.addOval(Rect.fromCircle(center: center, radius: radius)); + } else { + final rect = targetRect!.inflate(padding); + holePath.addRRect( + RRect.fromRectAndRadius(rect, const Radius.circular(16)), + ); + } + + // Cutout difference path + final cutoutPath = Path.combine(PathOperation.difference, screenPath, holePath); + + // 1. Draw dimmed backdrop + canvas.drawPath( + cutoutPath, + Paint() + ..color = const Color(0xFF0F1123).withValues(alpha: 0.72) + ..style = PaintingStyle.fill, + ); + + // 2. Draw subtle ambient halo ring around hole + final haloGlowPaint = Paint() + ..color = AppColors.primaryLight.withValues(alpha: haloOpacity * 0.45) + ..style = PaintingStyle.stroke + ..strokeWidth = 3.0 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); + canvas.drawPath(holePath, haloGlowPaint); + + // 3. Draw clean crisp highlight border on hole edge + final borderPaint = Paint() + ..color = AppColors.primaryContainer.withValues(alpha: haloOpacity) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawPath(holePath, borderPaint); + } + + @override + bool shouldRepaint(covariant _SpotlightCutoutPainter oldDelegate) { + return oldDelegate.targetRect != targetRect || + oldDelegate.shape != shape || + oldDelegate.padding != padding || + oldDelegate.haloOpacity != haloOpacity; + } +} 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/fallback_voice_call_repository.dart b/apps/eva_mobile/lib/features/voice_call/data/repositories/fallback_voice_call_repository.dart new file mode 100644 index 0000000..4549e26 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/data/repositories/fallback_voice_call_repository.dart @@ -0,0 +1,41 @@ +import '../../domain/repositories/voice_call_repository.dart'; +import '../../domain/entities/voice_session_entity.dart'; +import '../../../../core/network/fallback_policy.dart'; + +class FallbackVoiceCallRepository implements VoiceCallRepository { + final VoiceCallRepository _remote; + final VoiceCallRepository _mock; + + FallbackVoiceCallRepository({ + required VoiceCallRepository remote, + required VoiceCallRepository mock, + }) : _remote = remote, + _mock = mock; + + @override + Future startCall() { + return FallbackExecutor.execute( + remoteCall: () => _remote.startCall(), + mockCall: () => _mock.startCall(), + operationName: 'startCall', + ); + } + + @override + Future endCall(String sessionId, int durationSeconds) { + return FallbackExecutor.execute( + remoteCall: () => _remote.endCall(sessionId, durationSeconds), + mockCall: () => _mock.endCall(sessionId, durationSeconds), + operationName: 'endCall($sessionId)', + ); + } + + @override + Future getSessionSummary(String sessionId) { + return FallbackExecutor.execute( + remoteCall: () => _remote.getSessionSummary(sessionId), + mockCall: () => _mock.getSessionSummary(sessionId), + operationName: 'getSessionSummary($sessionId)', + ); + } +} diff --git a/apps/eva_mobile/lib/features/voice_call/data/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..d2db5f6 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/data/services/speech_recognition_service.dart @@ -0,0 +1,257 @@ +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); + } + }, + 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..056d393 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/presentation/providers/voice_call_provider.dart @@ -0,0 +1,23 @@ +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'; +import '../../data/repositories/fallback_voice_call_repository.dart'; + +final voiceCallRepositoryProvider = Provider((ref) { + final mockRepo = VoiceCallRepositoryImpl(MockVoiceCallDataSource()); + final remoteRepo = VoiceCallRepositoryImpl(RemoteVoiceCallDataSource()); + + if (AppConfig.useMockData) { + return mockRepo; + } else if (AppConfig.useHybridFallback) { + return FallbackVoiceCallRepository(remote: remoteRepo, mock: mockRepo); + } else { + return remoteRepo; + } +}); + +final activeVoiceSessionProvider = StateProvider((ref) => null); diff --git a/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_call_screen.dart b/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_call_screen.dart new file mode 100644 index 0000000..4ae3174 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_call_screen.dart @@ -0,0 +1,1141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/features/voice_call/domain/entities/call_state.dart'; +import 'package:eva_mobile/features/voice_call/presentation/controllers/ai_call_controller.dart'; +import 'package:eva_mobile/features/voice_call/presentation/controllers/ai_call_state.dart'; +import 'package:eva_mobile/features/voice_call/presentation/providers/ai_call_provider.dart'; +import 'package:eva_mobile/features/voice_call/presentation/widgets/morphing_voice_nodes_visualizer.dart'; +import 'package:eva_mobile/services/daily_progress_local_service.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/features/emergency/presentation/screens/support_emergency_screen.dart'; + +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: [ + AppBackButton.dark( + onPressed: () => _handleExitCall(state), + ), + Expanded( + child: Center( + child: Text( + _formatDuration(state.callDurationSeconds), + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white70, + letterSpacing: 0.5, + ), + ), + ), + ), + IconButton( + icon: const Icon(Icons.settings_outlined, color: Colors.white), + onPressed: () => _showSettingsBottomSheet(context), + ), + ], + ), + ), + + const Spacer(), + + // Center Avatar Orb with Multi-Layer Ripple Wave Animation + _buildInteractiveAvatarOrb(state), + + const SizedBox(height: 24), + + // Title & Clean Subtitle Info + Text( + 'EVA AI Assistant', + style: GoogleFonts.inter( + fontSize: 22, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + Text( + _getCleanStatusText(state), + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + color: state.isMuted ? const Color(0xFFFF8B94) : Colors.white70, + letterSpacing: 0.2, + ), + ), + + // Dynamic Live Transcript preview + if (state.latestPartial.isNotEmpty || state.currentTranscript.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 8.0), + child: Text( + '"${state.latestPartial.isNotEmpty ? state.latestPartial : state.currentTranscript}"', + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 13, + fontStyle: FontStyle.italic, + color: Colors.white60, + ), + ), + ), + + + + const Spacer(), + + // INTERACTIVE ACTION CONTROLS + // 1. In LISTENING: "Selesai Bicara" (Manual Force Commit) + // 2. In AI_SPEAKING or THINKING: "Bicara (Potong AI)" (Manual Barge-In) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: _buildMainActionButton(state, controller), + ), + + const SizedBox(height: 24), + + // Bottom Control Bar (Mute, End Call, Speaker) + Padding( + padding: const EdgeInsets.fromLTRB(36.0, 0, 36.0, 32.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Mute Microphone Button + GestureDetector( + onTap: () => controller.toggleMute(), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: state.isMuted + ? const Color(0xFFE53935) + : Colors.white.withValues(alpha: 0.15), + ), + child: Icon( + state.isMuted ? Icons.mic_off : Icons.mic, + color: Colors.white, + size: 26, + ), + ), + ), + + // End Call Button + GestureDetector( + onTap: () => _handleExitCall(state), + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFD32F2F), + ), + child: const Icon( + Icons.call_end, + color: Colors.white, + size: 30, + ), + ), + ), + + // Speakerphone Toggle Button + GestureDetector( + onTap: () => controller.toggleSpeaker(), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: state.isSpeakerOn + ? const Color(0xFF3F51B5) + : Colors.white.withValues(alpha: 0.15), + ), + child: Icon( + state.isSpeakerOn ? Icons.volume_up_rounded : Icons.volume_down_rounded, + color: Colors.white, + size: 26, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); +} + + + + Widget _buildMainActionButton(AiCallViewState state, AiCallController controller) { + if (state.conversationMode == ConversationMode.pushToTalk) { + return GestureDetector( + onTapDown: (_) => controller.pushToTalkPress(), + onTapUp: (_) => controller.pushToTalkRelease(), + onTapCancel: () => controller.pushToTalkRelease(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + decoration: BoxDecoration( + color: state.callState == CallState.listening + ? const Color(0xFFE53935) + : AppColors.primary, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.touch_app, color: Colors.white, size: 20), + const SizedBox(width: 8), + Text( + 'Tahan untuk Bicara', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } + + // Hybrid Auto Mode Dynamic Action Button + if (state.callState == CallState.aiSpeaking) { + // Barge-in / Jeda AI button (Simple & elegant, matching Selesai Bicara style) + return ElevatedButton.icon( + onPressed: () => controller.bargeIn(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF59E0B), + foregroundColor: Colors.white, + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + side: BorderSide( + color: Colors.white.withValues(alpha: 0.25), + width: 1, + ), + ), + ), + icon: const Icon(Icons.pause_rounded, color: Colors.white, size: 20), + label: Text( + 'Jeda AI', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 0.2, + ), + ), + ); + } else if (state.callState == CallState.thinking) { + // Non-interactive thinking badge to prevent accidental tap cancellation + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary), + ), + const SizedBox(width: 10), + Text( + 'Eva sedang memproses...', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white70, + ), + ), + ], + ), + ); + } else if (state.callState == CallState.listening && + (state.currentTranscript.isNotEmpty || state.latestPartial.isNotEmpty)) { + // Force Commit button (Only when user has actually spoken something) + return ElevatedButton.icon( + onPressed: () => controller.forceCommit(), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + ), + icon: const Icon(Icons.send_rounded, color: Colors.white, size: 18), + label: Text( + 'Selesai Bicara', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ); + } + + return const SizedBox(height: 48); + } + + String _getCleanStatusText(AiCallViewState state) { + if (state.isMuted) return 'Mikrofon Di-Mute'; + + switch (state.callState) { + case CallState.listening: + return state.isUserSpeaking + ? 'Mendengarkan (Anda sedang berbicara)' + : 'Mendengarkan...'; + case CallState.thinking: + return 'AI Memproses...'; + case CallState.aiSpeaking: + return 'EVA Sedang Berbicara...'; + case CallState.interrupting: + return 'Memotong Suara EVA...'; + case CallState.idle: + case CallState.ended: + case CallState.error: + return state.errorMessage ?? 'Standby'; + } + } + + Widget _buildInteractiveAvatarOrb(AiCallViewState state) { + final bool isAiSpeaking = state.callState == CallState.aiSpeaking; + final double soundLevel = state.isMuted ? 0.0 : state.soundLevel; + + return RepaintBoundary( + child: AnimatedBuilder( + animation: _aiRippleController, + builder: (context, child) { + final animValue = _aiRippleController.value; + + return Center( + child: SizedBox( + width: 280, + height: 280, + child: Stack( + alignment: Alignment.center, + children: [ + // 1. RADIAL FREQUENCY EQUALIZER BARS (CIRCULAR AUDIO SPECTRUM) + CustomPaint( + size: const Size(280, 280), + painter: RadialFrequencyBarsPainter( + animationProgress: animValue, + soundLevel: soundLevel, + isAiSpeaking: isAiSpeaking, + isMuted: state.isMuted, + ), + ), + + // 2. SOFT AMBIENT IRIDESCENT HALO + Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: const Color(0xFFC7D2FE).withValues( + alpha: state.isMuted ? 0.08 : 0.20 + ((isAiSpeaking ? soundLevel : 0.0) * 0.25), + ), + blurRadius: 36 + ((isAiSpeaking ? soundLevel : 0.0) * 20), + spreadRadius: 3, + ), + BoxShadow( + color: const Color(0xFFFFD1DC).withValues( + alpha: state.isMuted ? 0.05 : 0.15 + ((isAiSpeaking ? soundLevel : 0.0) * 0.15), + ), + blurRadius: 28, + spreadRadius: 2, + ), + ], + ), + ), + + // 3. MAIN ORB (PASTEL / IRIDESCENT SPHERE) + Container( + width: 174, + height: 174, + decoration: BoxDecoration( + shape: BoxShape.circle, + // Soft iridescent pastel gradient exactly like the design + gradient: const RadialGradient( + center: Alignment(-0.25, -0.35), + radius: 0.95, + colors: [ + Color(0xFFFFFFFF), // soft white highlight + Color(0xFFEDE9FE), // pale lavender + Color(0xFFE0E7FF), // soft periwinkle + Color(0xFFBAE6FD), // soft cyan edge + ], + stops: [0.0, 0.45, 0.75, 1.0], + ), + border: Border.all( + // Thin subtle iridescent outer border + color: const Color(0xFFE2E8F0).withValues(alpha: 0.7), + width: 1.5, + ), + boxShadow: [ + // Subtle rim illumination + BoxShadow( + color: const Color(0xFF818CF8).withValues(alpha: 0.25), + blurRadius: 16, + spreadRadius: 1, + ), + BoxShadow( + color: const Color(0xFFF472B6).withValues(alpha: 0.20), + blurRadius: 20, + spreadRadius: -2, + offset: const Offset(4, -2), + ), + ], + ), + child: Center( + // 4. UNIFIED MORPHING VOICE NODES VISUALIZER + // Seamless pure code transformation: + // Listening / User Speaking / AI Speaking: Equalizer bars / flat dots + // Thinking: Nodes morph, expand, orbit in circle with glowing aurora colors, and return smoothly. + child: MorphingVoiceNodesVisualizer( + callState: state.callState, + soundLevel: soundLevel, + isAiSpeaking: isAiSpeaking, + animValue: animValue, + isMuted: state.isMuted, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); +} +} diff --git a/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_session_detail_screen.dart b/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_session_detail_screen.dart new file mode 100644 index 0000000..6f271c1 --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_session_detail_screen.dart @@ -0,0 +1,751 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/data/repositories/voice_call_repository.dart'; +import 'package:eva_mobile/features/diary/presentation/providers/diary_provider.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/widgets/staggered_entrance.dart'; + +class VoiceSessionDetailScreen extends ConsumerStatefulWidget { + final Map? sessionData; + + const VoiceSessionDetailScreen({ + super.key, + this.sessionData, + }); + + @override + ConsumerState createState() => _VoiceSessionDetailScreenState(); +} + +class _VoiceSessionDetailScreenState extends ConsumerState { + 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: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Detail Sesi Suara', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + ), + + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), + child: Column( + children: [ + // Header Card: Title (with Skeleton Loading support), Date, Duration Badge + StaggeredEntrance( + index: 1, + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.volume_up_rounded, + color: AppColors.primary, + size: 26, + ), + ), + const SizedBox(width: 14), + Expanded( + child: isTitleLoading + ? _buildTitleSkeleton() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + '$date • $duration', + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 20), + + // 1. ANALISIS PARAMETER EMOSI (Progress Chart Section) + StaggeredEntrance( + index: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.analytics_outlined, + color: AppColors.primary, + size: 18, + ), + ), + const SizedBox(width: 10), + Text( + 'ANALISIS PARAMETER EMOSI', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w800, + color: AppColors.primary, + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 10), + + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + children: [ + ...emotionsBreakdown.map((emo) { + final double pct = (emo['percent'] as double).clamp(0.0, 1.0); + final int pctInt = (pct * 100).round(); + return Padding( + padding: const EdgeInsets.only(bottom: 14.0), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text(emo['emoji'].toString(), style: const TextStyle(fontSize: 16)), + const SizedBox(width: 8), + Text( + emo['label'].toString(), + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + Text( + '$pctInt%', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: pct, + minHeight: 8, + backgroundColor: const Color(0xFFE2E4F0), + valueColor: AlwaysStoppedAnimation(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 = ref.read(diaryRepositoryProvider); + final entry = await diaryRepo.getTodayDiary() ?? 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/features/voice_call/presentation/widgets/morphing_voice_nodes_visualizer.dart b/apps/eva_mobile/lib/features/voice_call/presentation/widgets/morphing_voice_nodes_visualizer.dart new file mode 100644 index 0000000..0d6ab6e --- /dev/null +++ b/apps/eva_mobile/lib/features/voice_call/presentation/widgets/morphing_voice_nodes_visualizer.dart @@ -0,0 +1,502 @@ +import "dart:math" as math; +import "package:flutter/material.dart"; + +import "../../domain/entities/call_state.dart"; + +class MorphingVoiceNodesVisualizer extends StatefulWidget { + final CallState callState; + final double soundLevel; + final bool isAiSpeaking; + final double animValue; + final bool isMuted; + + const MorphingVoiceNodesVisualizer({ + super.key, + required this.callState, + required this.soundLevel, + required this.isAiSpeaking, + required this.animValue, + required this.isMuted, + }); + + @override + State createState() => MorphingVoiceNodesVisualizerState(); +} + +class MorphingVoiceNodesVisualizerState extends State + 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 (Eva Tertiary) + // Node 1 (Mid-Left): Electric Periwinkle (Eva Primary) + // Node 2 (Center): Deep Violet Orchid + // Node 3 (Mid-Right): Radiant Fuchsia Pink + // Node 4 (Right): Sunset Coral Rose + static const List<_NodeColorProfile> _profiles = [ + _NodeColorProfile(Color(0xFF0284C7), Color(0xFF38BDF8), Color(0xFFA7E6FF)), + _NodeColorProfile(Color(0xFF4F46E5), Color(0xFF6366F1), Color(0xFFC7D2FE)), + _NodeColorProfile(Color(0xFF7E22CE), Color(0xFFA855F7), Color(0xFFE9D5FF)), + _NodeColorProfile(Color(0xFFDB2777), Color(0xFFF472B6), Color(0xFFFCE7F3)), + _NodeColorProfile(Color(0xFFE11D48), Color(0xFFFB7185), Color(0xFFFFE4E6)), + ]; + + static const Color _defaultIndigo = Color(0xFF5358CB); // Eva Primary + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + const int nodeCount = 5; + const double barWidth = 4.8; + const double lineSpacing = 10.5; + + // 1. FLUID ABSTRACT BACKGROUND BLEND (ACTIVE DURING THINKING) + if (morphProgress > 0.01 && !isMuted) { + // Soft atmospheric backdrop within the sphere (contained center spread) + final backdropPaint = Paint() + ..color = const Color(0xFFEDE9FE).withValues(alpha: 0.35 * morphProgress) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 16.0); + canvas.drawCircle(center, 44.0 * morphProgress, backdropPaint); + + // Render the 5 expanding organic fluid lobes + for (int i = 0; i < nodeCount; i++) { + final profile = _profiles[i]; + // 1.0x integer revolution ensures 100% seamless continuity when loop repeats + final double theta = (i * 2 * math.pi / nodeCount) + (animProgress * 2 * math.pi * 1.0); + + // Controlled Lissajous displacement with integer harmonic cycles + final double lissajousX = math.cos(theta) * (18.0 + math.sin(animProgress * 2 * math.pi * 1.0 + i) * 4.0); + final double lissajousY = math.sin(theta) * (16.0 + math.cos(animProgress * 2 * math.pi * 1.0 - i) * 4.0); + final Offset linePos = Offset(center.dx + (i - 2) * lineSpacing, center.dy); + final Offset orbitPos = center + Offset(lissajousX, lissajousY); + final Offset lobeCenter = Offset.lerp(linePos, orbitPos, morphProgress)!; + + // Controlled fluid radius with 2.0x integer harmonic breathing wave + final double baseRadius = Tween(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/main.dart b/apps/eva_mobile/lib/main.dart new file mode 100644 index 0000000..dc492df --- /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-compatible alias for existing tests +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..8ba68b6 --- /dev/null +++ b/apps/eva_mobile/lib/providers/daily_progress_provider.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../features/chat/domain/entities/conversation_entity.dart'; +import '../features/chat/domain/repositories/chat_repository.dart'; +import '../features/chat/presentation/providers/chat_provider.dart'; +import '../features/diary/domain/repositories/diary_repository.dart'; +import '../features/diary/presentation/providers/diary_provider.dart'; +import '../services/daily_progress_local_service.dart'; + +/// Notifier managing real-time reactive updates for the user's daily self-care progress. +class DailyProgressNotifier extends StateNotifier { + final ChatRepository _chatRepo; + final DiaryRepository _diaryRepo; + StreamSubscription? _streamSub; + + DailyProgressNotifier({ + required ChatRepository chatRepo, + required DiaryRepository diaryRepo, + }) : _chatRepo = chatRepo, + _diaryRepo = diaryRepo, + 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 repositories + Future syncWithServer() async { + try { + final results = await Future.wait([ + // 0: Conversations today + _chatRepo.getConversations().catchError((_) => []), + // 1: Diary today + _diaryRepo.getTodayDiary().catchError((_) => null), + ]); + + bool hasConv = state.hasConversation; + bool hasDiary = state.hasDiary; + + final convList = results[0] as List; + if (convList.isNotEmpty) { + hasConv = true; + } + + final diary = results[1]; + if (diary != 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( + chatRepo: ref.watch(chatRepositoryProvider), + diaryRepo: ref.watch(diaryRepositoryProvider), + ); +}); 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..911ba9d --- /dev/null +++ b/apps/eva_mobile/lib/services/daily_progress_local_service.dart @@ -0,0 +1,185 @@ +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)); + + 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..d740d7b --- /dev/null +++ b/apps/eva_mobile/lib/services/stt_service.dart @@ -0,0 +1,136 @@ +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); + } + } + }, + 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..2cd21f7 --- /dev/null +++ b/apps/eva_mobile/lib/services/vad_audio_service.dart @@ -0,0 +1,215 @@ +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( + 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/app_back_button.dart b/apps/eva_mobile/lib/widgets/app_back_button.dart new file mode 100644 index 0000000..b7c0ae8 --- /dev/null +++ b/apps/eva_mobile/lib/widgets/app_back_button.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; + +/// Standardized Circular Frosted Back Button for Eva Mobile. +/// +/// Follows the signature glassmorphic design established in SettingsScreen: +/// - Translucent circular badge (`BoxShape.circle`) +/// - Subtle border (`#E2E8F0` on light surfaces) +/// - Rounded back chevron (`Icons.arrow_back_rounded`) +/// - Automatic `Navigator.maybePop(context)` fallback when [onPressed] is omitted. +class AppBackButton extends StatelessWidget { + final VoidCallback? onPressed; + final bool isDark; + final IconData icon; + final double iconSize; + final double size; + final String? tooltip; + final Color? backgroundColor; + final Color? borderColor; + final Color? iconColor; + + const AppBackButton({ + super.key, + this.onPressed, + this.isDark = false, + this.icon = Icons.arrow_back_rounded, + this.iconSize = 20.0, + this.size = 40.0, + this.tooltip = 'Kembali', + this.backgroundColor, + this.borderColor, + this.iconColor, + }); + + /// Factory constructor optimized for dark backgrounds and gradients (e.g. VoiceCallScreen). + const AppBackButton.dark({ + super.key, + this.onPressed, + this.icon = Icons.arrow_back_rounded, + this.iconSize = 20.0, + this.size = 40.0, + this.tooltip = 'Kembali', + this.backgroundColor, + this.borderColor, + this.iconColor, + }) : isDark = true; + + @override + Widget build(BuildContext context) { + final effectiveBgColor = backgroundColor ?? + (isDark ? Colors.white.withValues(alpha: 0.15) : Colors.white.withValues(alpha: 0.8)); + final effectiveBorderColor = borderColor ?? + (isDark ? Colors.white.withValues(alpha: 0.25) : const Color(0xFFE2E8F0)); + final effectiveIconColor = iconColor ?? + (isDark ? Colors.white : AppColors.textPrimary); + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: effectiveBgColor, + shape: BoxShape.circle, + border: Border.all(color: effectiveBorderColor), + ), + child: IconButton( + icon: Icon(icon, size: iconSize, color: effectiveIconColor), + color: effectiveIconColor, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + splashRadius: size / 2, + tooltip: tooltip, + onPressed: onPressed ?? () => Navigator.maybePop(context), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/widgets/app_snack_bar.dart b/apps/eva_mobile/lib/widgets/app_snack_bar.dart new file mode 100644 index 0000000..17bc99b --- /dev/null +++ b/apps/eva_mobile/lib/widgets/app_snack_bar.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../theme/app_colors.dart'; + +/// Semantic variants for application-wide snackbars. +enum SnackBarVariant { + primary, + success, + warning, + danger, +} + +/// Standardized, stylish floating SnackBar utility for Eva Mobile. +class AppSnackBar { + const AppSnackBar._(); + + /// Resolve background color based on variant. + static Color getBackgroundColor(SnackBarVariant variant) { + switch (variant) { + case SnackBarVariant.primary: + return AppColors.primary; + case SnackBarVariant.success: + return const Color(0xFF10B981); // Emerald + case SnackBarVariant.warning: + return const Color(0xFFF59E0B); // Amber + case SnackBarVariant.danger: + return const Color(0xFFDC2626); // Crimson + } + } + + /// Resolve default leading icon based on variant. + static IconData getDefaultIcon(SnackBarVariant variant) { + switch (variant) { + case SnackBarVariant.primary: + return Icons.auto_awesome_rounded; + case SnackBarVariant.success: + return Icons.check_circle_outline_rounded; + case SnackBarVariant.warning: + return Icons.warning_amber_rounded; + case SnackBarVariant.danger: + return Icons.error_outline_rounded; + } + } + + /// Builds a standardized floating [SnackBar] widget. + static SnackBar create({ + required String message, + SnackBarVariant variant = SnackBarVariant.primary, + IconData? icon, + Duration duration = const Duration(seconds: 3), + SnackBarAction? action, + Widget? leading, + }) { + return SnackBar( + content: Row( + children: [ + leading ?? + Icon( + icon ?? getDefaultIcon(variant), + color: Colors.white, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: GoogleFonts.inter( + fontSize: 13, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + backgroundColor: getBackgroundColor(variant), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: duration, + action: action, + ); + } + + /// Shows the SnackBar on the given [BuildContext]. + static void show( + BuildContext context, { + required String message, + SnackBarVariant variant = SnackBarVariant.primary, + IconData? icon, + Duration duration = const Duration(seconds: 3), + SnackBarAction? action, + Widget? leading, + }) { + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + create( + message: message, + variant: variant, + icon: icon, + duration: duration, + action: action, + leading: leading, + ), + ); + } + + /// Shows the SnackBar on a pre-captured [ScaffoldMessengerState] (useful across async gaps). + static void showWithMessenger( + ScaffoldMessengerState messenger, { + required String message, + SnackBarVariant variant = SnackBarVariant.primary, + IconData? icon, + Duration duration = const Duration(seconds: 3), + SnackBarAction? action, + Widget? leading, + }) { + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + create( + message: message, + variant: variant, + icon: icon, + duration: duration, + action: action, + leading: leading, + ), + ); + } + + /// Quick helper for Success notification (Emerald green). + static void showSuccess( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), + IconData? icon, + SnackBarAction? action, + }) { + show( + context, + message: message, + variant: SnackBarVariant.success, + duration: duration, + icon: icon, + action: action, + ); + } + + /// Quick helper for Warning notification (Amber). + static void showWarning( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), + IconData? icon, + SnackBarAction? action, + }) { + show( + context, + message: message, + variant: SnackBarVariant.warning, + duration: duration, + icon: icon, + action: action, + ); + } + + /// Quick helper for Error/Danger notification (Crimson red). + static void showError( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 4), + IconData? icon, + SnackBarAction? action, + }) { + show( + context, + message: message, + variant: SnackBarVariant.danger, + duration: duration, + icon: icon, + action: action, + ); + } + + /// Quick helper for Primary notification (Periwinkle). + static void showPrimary( + BuildContext context, + String message, { + Duration duration = const Duration(seconds: 3), + IconData? icon, + SnackBarAction? action, + Widget? leading, + }) { + show( + context, + message: message, + variant: SnackBarVariant.primary, + duration: duration, + icon: icon, + action: action, + leading: leading, + ); + } +} diff --git a/apps/eva_mobile/lib/widgets/custom_button.dart b/apps/eva_mobile/lib/widgets/custom_button.dart 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..be3c6ff --- /dev/null +++ b/apps/eva_mobile/lib/widgets/emergency_contact_bottom_sheet.dart @@ -0,0 +1,549 @@ + +import 'package:flutter/material.dart'; + +import 'package:google_fonts/google_fonts.dart'; + +import '../theme/app_colors.dart'; +import 'app_snack_bar.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: () { + AppSnackBar.showError( + context, + 'Menghubungi ${_nameController.text.trim()} (${_phoneController.text.trim()})...', + icon: Icons.phone_in_talk_rounded, + ); + }, + + ), + + const SizedBox(height: 12), + + ], + + ), + ), + ); + } + +} + diff --git a/apps/eva_mobile/lib/widgets/eva_loading_orb.dart b/apps/eva_mobile/lib/widgets/eva_loading_orb.dart new file mode 100644 index 0000000..0fad757 --- /dev/null +++ b/apps/eva_mobile/lib/widgets/eva_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 Eva Orb. +class _NodeColorProfile { + final Color core; + final Color highlight; + final Color ambient; + const _NodeColorProfile(this.core, this.highlight, this.ambient); +} + +/// A mesmerizing, fluid iridescent loading orb inspired by Eva AI's voice visualizer. +/// Displays an undulating iridescent sphere with organic swirling fluid lobes, +/// pulsing halo, and an optional calm breathing status message. +class EvaLoadingOrb extends StatefulWidget { + final double size; + final String? message; + final bool showHalo; + + const EvaLoadingOrb({ + super.key, + this.size = 180.0, + this.message, + this.showHalo = true, + }); + + @override + State createState() => _EvaLoadingOrbState(); +} + +class _EvaLoadingOrbState 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: _EvaOrbFluidPainter( + animProgress: animProgress, + ), + ), + ), + ), + ], + ), + ), + + // Optional Status Message + if (widget.message != null && widget.message!.isNotEmpty) ...[ + const SizedBox(height: 18), + AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: 0.75 + (pulse * 0.25), + child: Text( + widget.message!, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + letterSpacing: 0.2, + ), + ), + ), + ], + ], + ); + }, + ), + ); + } +} + +/// CustomPainter that renders organic swirling fluid lobes and silky ribbons. +class _EvaOrbFluidPainter extends CustomPainter { + final double animProgress; + + const _EvaOrbFluidPainter({required this.animProgress}); + + static const List<_NodeColorProfile> _profiles = [ + _NodeColorProfile(Color(0xFF0284C7), Color(0xFF38BDF8), Color(0xFFA7E6FF)), + _NodeColorProfile(Color(0xFF4F46E5), Color(0xFF6366F1), Color(0xFFC7D2FE)), + _NodeColorProfile(Color(0xFF7E22CE), Color(0xFFA855F7), Color(0xFFE9D5FF)), + _NodeColorProfile(Color(0xFFDB2777), Color(0xFFF472B6), Color(0xFFFCE7F3)), + _NodeColorProfile(Color(0xFFE11D48), Color(0xFFFB7185), Color(0xFFFFE4E6)), + ]; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final double scale = size.width / 174.0; + const int nodeCount = 5; + + // 1. Fluid Abstract Background Atmosphere + final backdropPaint = Paint() + ..color = const Color(0xFFEDE9FE).withValues(alpha: 0.40) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 16.0 * scale); + canvas.drawCircle(center, 44.0 * scale, backdropPaint); + + // 2. Swirling Organic Fluid Lobes + for (int i = 0; i < nodeCount; i++) { + final profile = _profiles[i]; + final double theta = (i * 2 * math.pi / nodeCount) + (animProgress * 2 * math.pi * 1.0); + + final double lissajousX = (math.cos(theta) * (18.0 + math.sin(animProgress * 2 * math.pi * 1.0 + i) * 4.0)) * scale; + final double lissajousY = (math.sin(theta) * (16.0 + math.cos(animProgress * 2 * math.pi * 1.0 - i) * 4.0)) * scale; + final Offset lobeCenter = center + Offset(lissajousX, lissajousY); + + final double baseRadius = 32.0 * scale; + final double breathing = math.sin((animProgress * 2 * math.pi * 2.0) + (i * 1.3)) * 4.5 * scale; + final double lobeRadius = baseRadius + breathing; + + final Path lobePath = Path(); + const int vertices = 16; + for (int v = 0; v <= vertices; v++) { + final double angle = (v / vertices) * 2 * math.pi; + final double wave1 = math.sin((angle * 2.0) + (animProgress * 2 * math.pi * 2.0) + i) * 0.16; + final double wave2 = math.cos((angle * 3.0) - (animProgress * 2 * math.pi * 1.0) + (i * 0.8)) * 0.10; + final double r = lobeRadius * (1.0 + wave1 + wave2); + final double vx = lobeCenter.dx + r * math.cos(angle); + final double vy = lobeCenter.dy + r * math.sin(angle); + if (v == 0) { + lobePath.moveTo(vx, vy); + } else { + lobePath.lineTo(vx, vy); + } + } + lobePath.close(); + + final lobePaint = Paint() + ..shader = RadialGradient( + center: Alignment( + -0.20 * math.cos(theta), + -0.20 * math.sin(theta), + ), + radius: 0.90, + colors: [ + profile.highlight.withValues(alpha: 0.82), + profile.core.withValues(alpha: 0.62), + profile.ambient.withValues(alpha: 0.22), + profile.core.withValues(alpha: 0.0), + ], + stops: const [0.0, 0.42, 0.75, 1.0], + ).createShader(Rect.fromCircle(center: lobeCenter, radius: lobeRadius * 1.2)) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 10.0 * scale); + + canvas.drawPath(lobePath, lobePaint); + } + + // 3. Curving Fluid Silk Ribbon + final double ribbonAngle = animProgress * 2 * math.pi * 1.0; + final Path ribbon = Path(); + ribbon.moveTo( + center.dx + 38 * scale * math.cos(ribbonAngle), + center.dy + 38 * scale * math.sin(ribbonAngle), + ); + ribbon.quadraticBezierTo( + center.dx + 14 * scale * math.cos(ribbonAngle + 1.8), + center.dy + 14 * scale * math.sin(ribbonAngle + 1.8), + center.dx + 38 * scale * math.cos(ribbonAngle + math.pi), + center.dy + 38 * scale * math.sin(ribbonAngle + math.pi), + ); + + final ribbonPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 7.5 * scale + ..strokeCap = StrokeCap.round + ..shader = SweepGradient( + center: Alignment.center, + startAngle: ribbonAngle, + endAngle: ribbonAngle + (2 * math.pi), + colors: [ + _profiles[0].highlight.withValues(alpha: 0.45), + _profiles[2].highlight.withValues(alpha: 0.45), + _profiles[3].highlight.withValues(alpha: 0.40), + _profiles[0].highlight.withValues(alpha: 0.45), + ], + ).createShader(Rect.fromCircle(center: center, radius: 42.0 * scale)) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 5.0 * scale); + + canvas.drawPath(ribbon, ribbonPaint); + + // 4. Specular Glass Glare Overlay + final glarePaint = Paint() + ..shader = RadialGradient( + center: const Alignment(-0.35, -0.45), + radius: 0.70, + colors: [ + Colors.white.withValues(alpha: 0.35), + Colors.white.withValues(alpha: 0.08), + Colors.white.withValues(alpha: 0.0), + ], + stops: const [0.0, 0.45, 1.0], + ).createShader(Rect.fromCircle(center: center, radius: 52.0 * scale)); + canvas.drawCircle(center, 50.0 * scale, glarePaint); + } + + @override + bool shouldRepaint(covariant _EvaOrbFluidPainter oldDelegate) { + return oldDelegate.animProgress != animProgress; + } +} + +/// Backwards-compatible alias for legacy screens. +typedef LunaLoadingOrb = EvaLoadingOrb; 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..a6385dc --- /dev/null +++ b/apps/eva_mobile/lib/widgets/floating_nav_bar.dart @@ -0,0 +1,276 @@ +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; + final VoidCallback? onCenterTap; + final Key? centerButtonKey; + final Key? journalTabKey; + final Key? trenTabKey; + final Key? careTabKey; + + const FloatingNavBar({ + super.key, + required this.currentIndex, + required this.onTap, + this.onCenterTap, + this.centerButtonKey, + this.journalTabKey, + this.trenTabKey, + this.careTabKey, + }); + + /// Map page currentIndex (0..3) to 5-slot layout column index: + /// 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( + key: journalTabKey, + 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( + key: trenTabKey, + flex: 1, + child: _buildSideNavItem( + index: 2, + iconUnselected: Icons.show_chart_outlined, + iconSelected: Icons.show_chart, + label: 'Tren', + ), + ), + + // Slot 4: Layanan (Care & Komunitas) + Expanded( + key: careTabKey, + flex: 1, + child: _buildSideNavItem( + index: 3, + iconUnselected: Icons.volunteer_activism_outlined, + iconSelected: Icons.volunteer_activism, + label: 'Layanan', + ), + ), + ], + ), + ), + ], + ); + }, + ), + ), + ), + + // Central Floating Circular Chat Action Button (100% Mathematically Centered) + Positioned( + bottom: 30, + child: GestureDetector( + key: centerButtonKey, + onTap: onCenterTap ?? () { + 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..41c7904 --- /dev/null +++ b/apps/eva_mobile/lib/widgets/luna_loading_orb.dart @@ -0,0 +1,2 @@ +// Backward compatibility export +export 'eva_loading_orb.dart'; 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..8476489 --- /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/test/core/network/fallback_policy_test.dart b/apps/eva_mobile/test/core/network/fallback_policy_test.dart new file mode 100644 index 0000000..8bcd273 --- /dev/null +++ b/apps/eva_mobile/test/core/network/fallback_policy_test.dart @@ -0,0 +1,59 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/core/network/api_client.dart'; +import 'package:eva_mobile/core/network/fallback_policy.dart'; + +void main() { + group('FallbackPolicy', () { + test('should return true for connection/socket error', () { + expect(FallbackPolicy.shouldFallback(const SocketException('Failed host lookup')), isTrue); + expect(FallbackPolicy.shouldFallback(TimeoutException('Request timed out')), isTrue); + expect(FallbackPolicy.shouldFallback(Exception('Connection refused')), isTrue); + }); + + test('should return true for HTTP 404 Not Found and HTTP 501 Not Implemented', () { + expect(FallbackPolicy.shouldFallback(ApiException('Not Found', statusCode: 404)), isTrue); + expect(FallbackPolicy.shouldFallback(ApiException('Not Implemented', statusCode: 501)), isTrue); + expect(FallbackPolicy.shouldFallback(ApiException('Connection dropped', statusCode: null)), isTrue); + }); + + test('should return false for business errors 401, 403, 422, and 500', () { + expect(FallbackPolicy.shouldFallback(ApiException('Unauthorized', statusCode: 401)), isFalse); + expect(FallbackPolicy.shouldFallback(ApiException('Forbidden', statusCode: 403)), isFalse); + expect(FallbackPolicy.shouldFallback(ApiException('Unprocessable Entity', statusCode: 422)), isFalse); + expect(FallbackPolicy.shouldFallback(ApiException('Internal Server Error', statusCode: 500)), isFalse); + }); + }); + + group('FallbackExecutor', () { + test('should execute remote successfully without fallback when remote succeeds', () async { + final result = await FallbackExecutor.execute( + remoteCall: () async => 'remote_ok', + mockCall: () async => 'mock_data', + operationName: 'testOperation', + ); + expect(result, equals('remote_ok')); + }); + + test('should fallback to mock when remote fails with 404', () async { + final result = await FallbackExecutor.execute( + remoteCall: () async => throw ApiException('Not found', statusCode: 404), + mockCall: () async => 'mock_data', + operationName: 'testOperation404', + ); + expect(result, equals('mock_data')); + }); + + test('should rethrow original exception when remote fails with 500', () async { + expect( + () async => FallbackExecutor.execute( + remoteCall: () async => throw ApiException('Backend bug', statusCode: 500), + mockCall: () async => 'mock_data', + operationName: 'testOperation500', + ), + throwsA(isA().having((e) => e.statusCode, 'statusCode', 500)), + ); + }); + }); +} diff --git a/apps/eva_mobile/test/features/care/care_provider_test.dart b/apps/eva_mobile/test/features/care/care_provider_test.dart new file mode 100644 index 0000000..9336bd7 --- /dev/null +++ b/apps/eva_mobile/test/features/care/care_provider_test.dart @@ -0,0 +1,165 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/features/care/data/models/psychologist_model.dart'; +import 'package:eva_mobile/features/care/data/models/clinic_model.dart'; +import 'package:eva_mobile/features/care/data/models/community_status_model.dart'; +import 'package:eva_mobile/features/care/data/datasources/mock_care_datasource.dart'; +import 'package:eva_mobile/features/care/domain/entities/consultation_session_entity.dart'; + +void main() { + group('Care Domain & Model Unit Tests', () { + test('PsychologistModel correctly serializes and deserializes', () { + final json = { + 'id': 'psy_test', + 'name': 'Test Psychologist, M.Psi.', + 'title': 'Psikolog Klinis', + 'registration_number': 'SIPP: 1234', + 'avatar_url': 'https://example.com/avatar.png', + 'rating': 4.9, + 'review_count': 25, + 'experience_years': 4, + 'specialties': ['Kecemasan', 'Stres'], + 'fee_per_session': 150000, + 'duration_minutes': 50, + 'available_online': true, + 'clinic_affiliation': 'Klinik Sehat', + 'bio': 'Test bio description', + }; + + final model = PsychologistModel.fromJson(json); + expect(model.id, 'psy_test'); + expect(model.name, 'Test Psychologist, M.Psi.'); + expect(model.isPsychiatrist, isFalse); + expect(model.specialties.length, 2); + expect(model.feePerSession, 150000); + + final outJson = model.toJson(); + expect(outJson['registration_number'], 'SIPP: 1234'); + expect(outJson['fee_per_session'], 150000); + }); + + test('Psychiatrist detection via title', () { + const psikiater = PsychologistModel( + id: 'dr_1', + name: 'dr. Budi, Sp.KJ', + title: 'Dokter Spesialis Kedokteran Jiwa', + registrationNumber: 'STR: 9876', + avatarUrl: '', + rating: 5.0, + reviewCount: 10, + experienceYears: 8, + specialties: ['Bipolar'], + feePerSession: 250000, + bio: 'Bio psikiater', + ); + + expect(psikiater.isPsychiatrist, isTrue); + }); + + test('ClinicModel correctly deserializes from JSON', () { + final json = { + 'id': 'clinic_test', + 'name': 'Klinik Mitra Sehat', + 'address': 'Jl. Sudirman No. 1', + 'city': 'Jakarta Selatan', + 'distance_km': 1.8, + 'photo_url': 'https://example.com/clinic.png', + 'phone': '021-123456', + 'facilities': ['Ruang Konseling', 'Apotek'], + 'rating': 4.85, + }; + + final clinic = ClinicModel.fromJson(json); + expect(clinic.name, 'Klinik Mitra Sehat'); + expect(clinic.distanceKm, 1.8); + expect(clinic.facilities.contains('Apotek'), isTrue); + }); + + test('CommunityStatusModel correctly handles state mutations', () { + final status = const CommunityStatusModel( + isMember: false, + isVipActive: false, + vipRemainingDays: 0, + ); + + final upgraded = status.copyWith( + isMember: true, + isVipActive: true, + vipRemainingDays: 30, + ); + + expect(upgraded.isMember, isTrue); + expect(upgraded.isVipActive, isTrue); + expect(upgraded.vipRemainingDays, 30); + }); + }); + + group('MockCareDataSource Functional Tests', () { + late MockCareDataSource dataSource; + + setUp(() { + dataSource = MockCareDataSource(); + }); + + test('Fetches default psychologists list', () async { + final list = await dataSource.getPsychologists(); + expect(list.isNotEmpty, isTrue); + expect(list.length, greaterThanOrEqualTo(3)); + }); + + test('Filters psychologists by psychiatrist category', () async { + final psychiatrists = await dataSource.getPsychologists(category: 'psychiatrist'); + expect(psychiatrists.isNotEmpty, isTrue); + for (final p in psychiatrists) { + expect(p.isPsychiatrist, isTrue); + } + }); + + test('Filters psychologists by search query', () async { + final results = await dataSource.getPsychologists(query: 'Amanda'); + expect(results.length, 1); + expect(results.first.name, contains('Amanda')); + }); + + test('Fetches clinics and filters by query', () async { + final clinics = await dataSource.getClinics(); + expect(clinics.isNotEmpty, isTrue); + + final bandungClinics = await dataSource.getClinics(query: 'Bandung'); + expect(bandungClinics.length, 1); + expect(bandungClinics.first.city, 'Bandung'); + }); + + test('Simulates joining Discord community with 1-month VIP trial', () async { + final initial = await dataSource.getCommunityStatus(); + expect(initial.isMember, isFalse); + expect(initial.isVipActive, isFalse); + + final joined = await dataSource.joinCommunityWithVipTrial(); + expect(joined.isMember, isTrue); + expect(joined.isVipActive, isTrue); + expect(joined.vipRemainingDays, 30); + }); + + test('Books a new consultation session and updates history', () async { + final initialSessions = await dataSource.getConsultationSessions(); + final initialCount = initialSessions.length; + + final newSession = await dataSource.bookConsultation( + psychologistId: 'psy_1', + preferredDateTime: DateTime.now().add(const Duration(days: 2)), + sessionType: ConsultationType.onlineVideo, + complaints: 'Merasa overthinking saat malam hari', + ); + + expect(newSession.id, startsWith('sess_')); + expect(newSession.psychologist.id, 'psy_1'); + expect(newSession.status, ConsultationStatus.upcoming); + expect(newSession.type, ConsultationType.onlineVideo); + expect(newSession.meetingUrl, isNotNull); + + final updatedSessions = await dataSource.getConsultationSessions(); + expect(updatedSessions.length, initialCount + 1); + expect(updatedSessions.first.id, newSession.id); + }); + }); +} diff --git a/apps/eva_mobile/test/features/care/care_screen_test.dart b/apps/eva_mobile/test/features/care/care_screen_test.dart new file mode 100644 index 0000000..5297628 --- /dev/null +++ b/apps/eva_mobile/test/features/care/care_screen_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:eva_mobile/features/care/presentation/screens/care_screen.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/community_banner_card.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/community_paywall_sheet.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/psychologist_card.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/clinic_card.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({ + 'user_name': 'Alexander Agung', + 'user_email': 'alex@example.com', + }); + }); + + Widget createWidgetUnderTest() { + return const ProviderScope( + child: MaterialApp( + home: CareScreen(), + ), + ); + } + + testWidgets('CareScreen renders header, banner, and filter chips', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Verify Title & Subtitle + expect(find.text('Layanan & Komunitas'), findsOneWidget); + expect(find.text('Bantuan klinis profesional & safe space'), findsOneWidget); + + // Verify Community Banner Card + expect(find.byType(CommunityBannerCard), findsOneWidget); + expect(find.text('KOMUNITAS SAFE SPACE'), findsOneWidget); + expect(find.text('Gabung Sekarang'), findsOneWidget); + + // Verify Filter Chips + expect(find.text('Semua'), findsOneWidget); + expect(find.text('Psikolog'), findsOneWidget); + expect(find.text('Psikiater'), findsOneWidget); + expect(find.text('Klinik Mitra'), findsOneWidget); + + // Verify Psychologist Card rendered from mock data + expect(find.byType(PsychologistCard), findsWidgets); + expect(find.text('Amanda Zahra, M.Psi., Psikolog'), findsOneWidget); + expect(find.text('SIPP Terdaftar'), findsWidgets); + }); + + testWidgets('Tapping Gabung Sekarang opens CommunityPaywallSheet', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Tap on Gabung Sekarang button + final joinBtn = find.text('Gabung Sekarang'); + expect(joinBtn, findsOneWidget); + await tester.tap(joinBtn); + await tester.pumpAndSettle(); + + // Verify Paywall Sheet is displayed + expect(find.byType(CommunityPaywallSheet), findsOneWidget); + expect(find.text('Eva Safe Space Community'), findsOneWidget); + expect(find.text('Akses Sekali Bayar Seumur Hidup'), findsOneWidget); + expect(find.text('Bonus 1 Bulan VIP Event Trial'), findsOneWidget); + }); + + testWidgets('Switching filter to Klinik Mitra renders ClinicCards', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Tap on Klinik Mitra chip + final clinicChip = find.text('Klinik Mitra'); + expect(clinicChip, findsOneWidget); + await tester.tap(clinicChip); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Verify ClinicCards are displayed instead of PsychologistCards + expect(find.byType(ClinicCard), findsWidgets); + expect(find.text('Klinik Utama Sahabat Jiwa'), findsOneWidget); + expect(find.byType(PsychologistCard), findsNothing); + }); +} diff --git a/apps/eva_mobile/test/features/monitoring/monitoring_screen_test.dart b/apps/eva_mobile/test/features/monitoring/monitoring_screen_test.dart index e72430e..7393f9a 100644 --- a/apps/eva_mobile/test/features/monitoring/monitoring_screen_test.dart +++ b/apps/eva_mobile/test/features/monitoring/monitoring_screen_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:eva_mobile/features/monitoring/data/models/monitoring_data_model.dart'; import 'package:eva_mobile/features/monitoring/domain/entities/monitoring_data_entity.dart'; import 'package:eva_mobile/features/monitoring/presentation/providers/monitoring_provider.dart'; -import 'package:eva_mobile/screens/monitoring_screen.dart'; +import 'package:eva_mobile/features/monitoring/presentation/screens/monitoring_screen.dart'; void main() { group('MonitoringDataModel Tests', () { diff --git a/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart b/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart new file mode 100644 index 0000000..5de55e0 --- /dev/null +++ b/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/branded_guidance_card.dart'; + +void main() { + group('BrandedGuidanceCard', () { + testWidgets('renders branded card with typography, badge, title, and description', + (tester) async { + bool skipped = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: BrandedGuidanceCard( + title: 'Cerita ke Luna, yuk!', + description: + 'Tekan tombol tengah untuk mulai berbicara. Kamu bisa mulai saat sudah siap.', + actionHint: 'Ketuk tombol di bawah untuk mencoba', + badgeText: 'LUNA AI', + onSkip: () => skipped = true, + ), + ), + ), + ), + ); + + // Verify title & description + expect(find.text('Cerita ke Luna, yuk!'), findsOneWidget); + expect( + find.text( + 'Tekan tombol tengah untuk mulai berbicara. Kamu bisa mulai saat sudah siap.'), + findsOneWidget, + ); + expect(find.text('LUNA AI'), findsOneWidget); + expect(find.text('Ketuk tombol di bawah untuk mencoba'), findsOneWidget); + expect(find.text('Lewati'), findsOneWidget); + + // Tap "Lewati" + await tester.tap(find.text('Lewati')); + expect(skipped, isTrue); + }); + + testWidgets('renders Lanjut button when isInteractiveTarget is false', + (tester) async { + bool nextClicked = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: BrandedGuidanceCard( + title: 'Jurnal Refleksi Otomatis', + description: + 'Setiap selesai bercerita, intisari renunganmu tersimpan di sini.', + isInteractiveTarget: false, + onSkip: () {}, + onNext: () => nextClicked = true, + ), + ), + ), + ), + ); + + expect(find.text('Lanjut'), findsOneWidget); + await tester.tap(find.text('Lanjut')); + expect(nextClicked, isTrue); + }); + }); +} diff --git a/apps/eva_mobile/test/features/tutorial/interactive_spotlight_overlay_test.dart b/apps/eva_mobile/test/features/tutorial/interactive_spotlight_overlay_test.dart new file mode 100644 index 0000000..ba7226e --- /dev/null +++ b/apps/eva_mobile/test/features/tutorial/interactive_spotlight_overlay_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; + +void main() { + group('InteractiveSpotlightOverlay', () { + testWidgets('allows tap through target cutout while blocking background taps', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + bool targetTapped = false; + bool backgroundTapped = false; + bool skipped = false; + + final targetKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Stack( + children: [ + // 1. Background button that SHOULD BE BLOCKED + Positioned( + top: 100, + left: 50, + child: ElevatedButton( + onPressed: () => backgroundTapped = true, + child: const Text('Background Button'), + ), + ), + + // 2. Target button that SHOULD BE REACHABLE + Positioned( + bottom: 50, + left: 170, + child: GestureDetector( + key: targetKey, + onTap: () => targetTapped = true, + child: Container( + width: 60, + height: 60, + color: Colors.blue, + child: const Center(child: Text('Target')), + ), + ), + ), + + // 3. Spotlight Overlay + InteractiveSpotlightOverlay( + targetKey: targetKey, + content: TutorialContent.stepContents[TutorialStep.voiceSession]!, + onSkip: () => skipped = true, + onTargetTap: () => targetTapped = true, + ), + ], + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Check card content rendered + expect(find.text('Cerita ke Luna, yuk!'), findsOneWidget); + + // Attempt tapping the background button outside the cutout + await tester.tap(find.text('Background Button')); + await tester.pump(); + expect( + backgroundTapped, + isFalse, + reason: 'Background button must be blocked by the dimmed overlay barrier', + ); + + // Tap the target through the cutout hole + await tester.tap(find.text('Target')); + await tester.pump(); + expect( + targetTapped, + isTrue, + reason: 'Target button must receive touch events directly through the cutout', + ); + + // Tap Lewati + await tester.tap(find.text('Lewati')); + expect(skipped, isTrue); + }); + }); +} diff --git a/apps/eva_mobile/test/features/tutorial/main_shell_tutorial_test.dart b/apps/eva_mobile/test/features/tutorial/main_shell_tutorial_test.dart new file mode 100644 index 0000000..f3388b5 --- /dev/null +++ b/apps/eva_mobile/test/features/tutorial/main_shell_tutorial_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:eva_mobile/features/home/presentation/screens/main_shell_screen.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('MainShellScreen Tutorial Integration', () { + testWidgets( + 'displays spotlight overlay on center button and navigates when center button is tapped', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + bool chatRoutePushed = false; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + routes: { + '/chat': (context) { + chatRoutePushed = true; + return const Scaffold(body: Text('Chat Screen Destination')); + }, + }, + home: const MainShellScreen(initialIndex: 0), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // 1. Overlay is visible with Luna tutorial card + expect(find.byType(InteractiveSpotlightOverlay), findsOneWidget); + expect(find.text('Cerita ke Luna, yuk!'), findsOneWidget); + expect(find.text('REKOMENDASI LUNA'), findsOneWidget); + + // 2. Tap the central action button (Icons.auto_awesome) directly through the spotlight cutout + await tester.tap(find.byIcon(Icons.auto_awesome)); + await tester.pumpAndSettle(); + + // 3. Navigation was triggered + expect(chatRoutePushed, isTrue); + expect(find.text('Chat Screen Destination'), findsOneWidget); + }); + + testWidgets('dismisses spotlight overlay when Lewati is tapped', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: MainShellScreen(initialIndex: 0), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byType(InteractiveSpotlightOverlay), findsOneWidget); + + // Tap Lewati + await tester.tap(find.text('Lewati')); + await tester.pumpAndSettle(); + + // Overlay dismissed + expect(find.byType(InteractiveSpotlightOverlay), findsNothing); + }); + }); +} diff --git a/apps/eva_mobile/test/features/tutorial/tutorial_provider_test.dart b/apps/eva_mobile/test/features/tutorial/tutorial_provider_test.dart new file mode 100644 index 0000000..2efe79b --- /dev/null +++ b/apps/eva_mobile/test/features/tutorial/tutorial_provider_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/domain/repositories/tutorial_repository.dart'; +import 'package:eva_mobile/features/tutorial/presentation/providers/tutorial_provider.dart'; + +class _FakeTutorialRepository implements TutorialRepository { + bool _completed = false; + + @override + Future hasCompletedTutorial() async => _completed; + + @override + Future setTutorialCompleted(bool completed) async { + _completed = completed; + } + + @override + Future resetTutorial() async { + _completed = false; + } +} + +void main() { + group('TutorialNotifier', () { + test('initializes and activates for new users', () async { + final repo = _FakeTutorialRepository(); + final container = ProviderContainer( + overrides: [ + tutorialRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + + // Wait for checkInitialStatus + await Future.delayed(const Duration(milliseconds: 10)); + + final state = container.read(tutorialNotifierProvider); + expect(state.isInitialized, isTrue); + expect(state.isActive, isTrue); + expect(state.currentStep, TutorialStep.voiceSession); + }); + + test('advances through steps until completed', () async { + final repo = _FakeTutorialRepository(); + final container = ProviderContainer( + overrides: [ + tutorialRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + + await Future.delayed(const Duration(milliseconds: 10)); + final notifier = container.read(tutorialNotifierProvider.notifier); + + // Voice session target tapped -> sets step to journal, pauses overlay for call + await notifier.onTargetTapped(TutorialStep.voiceSession); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.journal); + expect(container.read(tutorialNotifierProvider).isActive, isFalse); + + // Resume at journal + notifier.resumeAtStep(TutorialStep.journal); + expect(container.read(tutorialNotifierProvider).isActive, isTrue); + + // Next step -> analytics + notifier.nextStep(); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.analytics); + + // Next step -> care + notifier.nextStep(); + expect( + container.read(tutorialNotifierProvider).currentStep, TutorialStep.care); + + // Next step -> completed + notifier.nextStep(); + expect(container.read(tutorialNotifierProvider).isCompleted, isTrue); + expect(container.read(tutorialNotifierProvider).isActive, isFalse); + }); + + test('skipTutorial dismisses for current session, ensureActiveOnLaunch resets on launch', () async { + final repo = _FakeTutorialRepository(); + final container = ProviderContainer( + overrides: [ + tutorialRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + + await Future.delayed(const Duration(milliseconds: 10)); + final notifier = container.read(tutorialNotifierProvider.notifier); + + await notifier.skipTutorial(); + expect(container.read(tutorialNotifierProvider).isCompleted, isTrue); + expect(container.read(tutorialNotifierProvider).isActive, isFalse); + + // On app reopen or restart, ensureActiveOnLaunch displays recommendation again + notifier.ensureActiveOnLaunch(); + expect(container.read(tutorialNotifierProvider).isActive, isTrue); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.voiceSession); + }); + }); +} diff --git a/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart b/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart index b678800..048b650 100644 --- a/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart +++ b/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart @@ -1,9 +1,27 @@ +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/screens/ai_conversation_screen.dart'; +import 'package:eva_mobile/features/chat/domain/entities/conversation_entity.dart'; +import 'package:eva_mobile/features/chat/domain/repositories/chat_repository.dart'; +import 'package:eva_mobile/features/chat/presentation/providers/chat_provider.dart'; +import 'package:eva_mobile/features/chat/presentation/screens/ai_conversation_screen.dart'; import 'package:eva_mobile/widgets/glass_card.dart'; +class _PendingChatRepository implements ChatRepository { + @override + Future> getConversations() => + Completer>().future; + + @override + Future getConversationById(String id) => + Completer().future; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + void main() { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -16,8 +34,10 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const MaterialApp( - home: AiConversationScreen(), + const ProviderScope( + child: MaterialApp( + home: AiConversationScreen(), + ), ), ); @@ -37,8 +57,10 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const MaterialApp( - home: AiConversationScreen(), + const ProviderScope( + child: MaterialApp( + home: AiConversationScreen(), + ), ), ); @@ -59,8 +81,13 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const MaterialApp( - home: AiConversationScreen(), + ProviderScope( + overrides: [ + chatRepositoryProvider.overrideWithValue(_PendingChatRepository()), + ], + child: const MaterialApp( + home: AiConversationScreen(), + ), ), ); diff --git a/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart b/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart index 243950c..58d56e6 100644 --- a/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart +++ b/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/screens/ai_diary_detail_screen.dart'; +import 'package:eva_mobile/features/diary/presentation/screens/ai_diary_detail_screen.dart'; void main() { setUp(() { diff --git a/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart b/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart index 36a1d7c..8a29500 100644 --- a/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart +++ b/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:eva_mobile/core/config/app_config.dart'; -import 'package:eva_mobile/screens/dass_assessment_screen.dart'; +import 'package:eva_mobile/features/dass/presentation/screens/dass_assessment_screen.dart'; void main() { setUp(() { diff --git a/apps/eva_mobile/test/screens/login_screen_test.dart b/apps/eva_mobile/test/screens/login_screen_test.dart new file mode 100644 index 0000000..1a76dbc --- /dev/null +++ b/apps/eva_mobile/test/screens/login_screen_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/login_screen.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('LoginScreen Demo Autofill Tests', () { + testWidgets('renders demo autofill button and populates seeded credentials when tapped', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const MaterialApp( + home: LoginScreen(), + ), + ); + + await tester.pumpAndSettle(); + + // Verify the demo autofill button is present + final demoButton = find.byKey(const Key('login_demo_autofill_button')); + expect(demoButton, findsOneWidget); + expect(find.text('Gunakan Akun Demo'), findsOneWidget); + + // Verify email and password fields are initially empty + expect(find.text(AppConfig.demoEmail), findsNothing); + expect(find.text(AppConfig.demoPassword), findsNothing); + + // Tap the demo autofill button + await tester.tap(demoButton); + await tester.pump(); + + // Verify fields now contain the seeded demo credentials + expect(find.text(AppConfig.demoEmail), findsOneWidget); + expect(find.text(AppConfig.demoPassword), findsOneWidget); + + // Verify confirmation SnackBar is displayed + expect( + find.text('Akun demo berhasil diisi (${AppConfig.demoEmail})'), + findsOneWidget, + ); + }); + }); +} diff --git a/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart b/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart index 460081f..6a89a34 100644 --- a/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart +++ b/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart @@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:eva_mobile/features/voice_call/presentation/providers/ai_call_provider.dart'; -import 'package:eva_mobile/screens/main_shell_screen.dart'; -import 'package:eva_mobile/screens/voice_call_screen.dart'; +import 'package:eva_mobile/features/home/presentation/screens/main_shell_screen.dart'; +import 'package:eva_mobile/features/voice_call/presentation/screens/voice_call_screen.dart'; import 'package:eva_mobile/widgets/floating_nav_bar.dart'; void main() { diff --git a/apps/eva_mobile/test/screens/settings_screen_test.dart b/apps/eva_mobile/test/screens/settings_screen_test.dart index 97d783e..36d6f30 100644 --- a/apps/eva_mobile/test/screens/settings_screen_test.dart +++ b/apps/eva_mobile/test/screens/settings_screen_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/screens/settings_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/settings_screen.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/apps/eva_mobile/test/screens/splash_onboarding_screen_test.dart b/apps/eva_mobile/test/screens/splash_onboarding_screen_test.dart index f740f58..a1752ea 100644 --- a/apps/eva_mobile/test/screens/splash_onboarding_screen_test.dart +++ b/apps/eva_mobile/test/screens/splash_onboarding_screen_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/screens/splash_onboarding_screen.dart'; +import 'package:eva_mobile/features/auth/presentation/screens/splash_onboarding_screen.dart'; void main() { setUp(() { diff --git a/apps/eva_mobile/test/widgets/app_back_button_test.dart b/apps/eva_mobile/test/widgets/app_back_button_test.dart new file mode 100644 index 0000000..7809974 --- /dev/null +++ b/apps/eva_mobile/test/widgets/app_back_button_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:eva_mobile/widgets/app_back_button.dart'; + +void main() { + group('AppBackButton Widget Tests', () { + testWidgets('renders default AppBackButton with rounded back icon and circle shape', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AppBackButton(), + ), + ), + ); + + final iconFinder = find.byIcon(Icons.arrow_back_rounded); + expect(iconFinder, findsOneWidget); + + final containerFinder = find.byType(Container); + expect(containerFinder, findsWidgets); + + final containerWidget = tester.widget( + find.descendant(of: find.byType(AppBackButton), matching: find.byType(Container)), + ); + final decoration = containerWidget.decoration as BoxDecoration; + expect(decoration.shape, BoxShape.circle); + + final iconWidget = tester.widget(iconFinder); + expect(iconWidget.size, 20.0); + }); + + testWidgets('calls Navigator.maybePop when tapped without custom onPressed', (tester) async { + bool didPop = false; + + await tester.pumpWidget( + MaterialApp( + home: Navigator( + onPopPage: (route, result) { + didPop = true; + return route.didPop(result); + }, + pages: const [ + MaterialPage(child: Scaffold(body: Text('Page 1'))), + MaterialPage(child: Scaffold(body: AppBackButton())), + ], + ), + ), + ); + + await tester.tap(find.byType(AppBackButton)); + await tester.pumpAndSettle(); + + expect(didPop, isTrue); + }); + + testWidgets('calls custom onPressed callback when supplied', (tester) async { + bool pressed = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AppBackButton( + onPressed: () => pressed = true, + ), + ), + ), + ); + + await tester.tap(find.byType(AppBackButton)); + await tester.pump(); + + expect(pressed, isTrue); + }); + + testWidgets('AppBackButton.dark renders with white icon and appropriate dark decoration', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AppBackButton.dark(), + ), + ), + ); + + final iconWidget = tester.widget(find.byIcon(Icons.arrow_back_rounded)); + expect(iconWidget.color, Colors.white); + + final containerWidget = tester.widget( + find.descendant(of: find.byType(AppBackButton), matching: find.byType(Container)), + ); + final decoration = containerWidget.decoration as BoxDecoration; + expect(decoration.shape, BoxShape.circle); + }); + + testWidgets('respects custom size, icon, and tooltip', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: AppBackButton( + size: 48.0, + iconSize: 24.0, + icon: Icons.close_rounded, + tooltip: 'Tutup', + ), + ), + ), + ); + + expect(find.byIcon(Icons.close_rounded), findsOneWidget); + expect(find.byTooltip('Tutup'), findsOneWidget); + + final containerWidget = tester.widget( + find.descendant(of: find.byType(AppBackButton), matching: find.byType(Container)), + ); + expect(containerWidget.constraints?.maxWidth, 48.0); + expect(containerWidget.constraints?.maxHeight, 48.0); + }); + }); +} diff --git a/apps/eva_mobile/test/widgets/app_snack_bar_test.dart b/apps/eva_mobile/test/widgets/app_snack_bar_test.dart new file mode 100644 index 0000000..88df07a --- /dev/null +++ b/apps/eva_mobile/test/widgets/app_snack_bar_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AppSnackBar Widget Tests', () { + testWidgets('renders primary variant with correct styling and icon', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + AppSnackBar.showPrimary(context, 'Pesan Primary'); + }, + child: const Text('Show'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Show')); + await tester.pump(); + + expect(find.text('Pesan Primary'), findsOneWidget); + expect(find.byIcon(Icons.auto_awesome_rounded), findsOneWidget); + + final snackBar = tester.widget(find.byType(SnackBar)); + expect(snackBar.behavior, SnackBarBehavior.floating); + expect(snackBar.backgroundColor, AppColors.primary); + }); + + testWidgets('renders success, warning, and danger variants with respective colors', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => Column( + children: [ + ElevatedButton( + onPressed: () => AppSnackBar.showSuccess(context, 'Sukses Simpan'), + child: const Text('Success'), + ), + ElevatedButton( + onPressed: () => AppSnackBar.showWarning(context, 'Peringatan Form'), + child: const Text('Warning'), + ), + ElevatedButton( + onPressed: () => AppSnackBar.showError(context, 'Terjadi Kesalahan'), + child: const Text('Danger'), + ), + ], + ), + ), + ), + ), + ); + + // Test Success + await tester.tap(find.text('Success')); + await tester.pump(); + expect(find.text('Sukses Simpan'), findsOneWidget); + expect(find.byIcon(Icons.check_circle_outline_rounded), findsOneWidget); + var snackBar = tester.widget(find.byType(SnackBar)); + expect(snackBar.backgroundColor, const Color(0xFF10B981)); + + // Test Warning (replaces previous snackbar immediately) + await tester.tap(find.text('Warning')); + await tester.pump(); + expect(find.text('Peringatan Form'), findsOneWidget); + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + snackBar = tester.widget(find.byType(SnackBar)); + expect(snackBar.backgroundColor, const Color(0xFFF59E0B)); + + // Test Danger + await tester.tap(find.text('Danger')); + await tester.pump(); + expect(find.text('Terjadi Kesalahan'), findsOneWidget); + expect(find.byIcon(Icons.error_outline_rounded), findsOneWidget); + snackBar = tester.widget(find.byType(SnackBar)); + expect(snackBar.backgroundColor, const Color(0xFFDC2626)); + }); + + testWidgets('supports custom leading widget and showWithMessenger', (tester) async { + late ScaffoldMessengerState messenger; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + messenger = ScaffoldMessenger.of(context); + return ElevatedButton( + onPressed: () { + AppSnackBar.showWithMessenger( + messenger, + message: 'Sedang Proses...', + variant: SnackBarVariant.primary, + leading: const SizedBox( + key: Key('custom_spinner'), + width: 16, + height: 16, + ), + ); + }, + child: const Text('Custom'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Custom')); + await tester.pump(); + + expect(find.text('Sedang Proses...'), findsOneWidget); + expect(find.byKey(const Key('custom_spinner')), findsOneWidget); + }); + }); +} diff --git a/apps/eva_mobile/test/widgets/floating_nav_bar_test.dart b/apps/eva_mobile/test/widgets/floating_nav_bar_test.dart index 5600be8..966ad16 100644 --- a/apps/eva_mobile/test/widgets/floating_nav_bar_test.dart +++ b/apps/eva_mobile/test/widgets/floating_nav_bar_test.dart @@ -21,7 +21,7 @@ void main() { expect(find.text('Beranda'), findsOneWidget); expect(find.text('Jurnal'), findsOneWidget); expect(find.text('Tren'), findsOneWidget); - expect(find.text('Profil'), findsOneWidget); + expect(find.text('Layanan'), findsOneWidget); expect(find.byIcon(Icons.auto_awesome), findsOneWidget); // Center FAB // Verify AnimatedPositioned pill exists @@ -40,6 +40,10 @@ void main() { await tester.tap(find.text('Tren')); expect(tappedIndex, 2); + // Tap on Layanan (Index 3) + await tester.tap(find.text('Layanan')); + expect(tappedIndex, 3); + // Re-render with currentIndex: 3 await tester.pumpWidget( MaterialApp( @@ -54,8 +58,8 @@ void main() { await tester.pumpAndSettle(); final pillBox = tester.getRect(find.byType(AnimatedPositioned)); - final iconBox = tester.getRect(find.byIcon(Icons.person)); - final textBox = tester.getRect(find.text('Profil')); + final iconBox = tester.getRect(find.byIcon(Icons.volunteer_activism)); + final textBox = tester.getRect(find.text('Layanan')); // Verify vertical centering of item column inside sliding pill final pillCenterY = pillBox.center.dy; diff --git a/apps/eva_mobile/test/widgets/home_screen_test.dart b/apps/eva_mobile/test/widgets/home_screen_test.dart index fc7a196..16d1fb5 100644 --- a/apps/eva_mobile/test/widgets/home_screen_test.dart +++ b/apps/eva_mobile/test/widgets/home_screen_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/screens/home_screen.dart'; +import 'package:eva_mobile/features/home/presentation/screens/home_screen.dart'; void main() { testWidgets( @@ -38,8 +38,8 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - // 1. Brand Header - expect(find.text('EVA'), findsOneWidget); + // 1. Brand Header removed per UI design + expect(find.text('EVA'), findsNothing); // 2. Greeting & Insight Card expect(find.textContaining('Halo, '), findsOneWidget); @@ -91,5 +91,36 @@ void main() { await tester.tap(find.text('Jurnal Refleksi AI')); expect(navigatedTab, 1); }); + + testWidgets('Tapping profile avatar opens dropdown modal with items', (WidgetTester tester) async { + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: HomeScreen(), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Find profile avatar container with circle shape + final avatarFinder = find.byWidgetPredicate( + (widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration as BoxDecoration).shape == BoxShape.circle, + ); + expect(avatarFinder, findsWidgets); + + await tester.tap(avatarFinder.first); + await tester.pumpAndSettle(); + + // Verify modal dropdown items from Gambar 2 + expect(find.text('User Profile'), findsOneWidget); + expect(find.text('Kontak Darurat'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + expect(find.text('Keluar Akun'), findsOneWidget); + }); } From 247734ff416bb518b3d9847d82139060fe1c18d3 Mon Sep 17 00:00:00 2001 From: ekyaaa Date: Sat, 7 Nov 2026 19:04:09 +0700 Subject: [PATCH 2/7] refactor(mobile): modularize monolithic screens into cohesive presentation widgets - Break down monolithic screens into modular domain presentation widgets: - Diary: extract fallback fixture, DiaryInsightCard, DiaryKeyEventsCard, DiaryReflectionCard, DiaryEmotionDistributionCard, and DiaryTranscriptSection (1,647 -> 538 lines). - DASS-21: extract DassSubscaleInfoSheet, DassSubscalesCard, DassQuestionCard, and DassFloatingSaveBar (1,641 -> 520 lines). - Home: extract HomeEvaInsightCard, HomeQuickActions, HomeCrisisBanner, HomeDailyProgressCard, and HomeRecommendationsSection (1,629 -> 590 lines). - Remove double-spacing boilerplate and inline styles across presentation screens. - Strengthen InteractiveSpotlightOverlay hit testing to allow direct tap-through via cutout hole and prevent pumpAndSettle timeout. - Align PsychologistEntity psychiatrist detection, CommunityStatusModel copyWith, and full Flutter test suite (99/99 tests passed, 0 analyzer issues). Related: #28 --- .../datasources/mock_care_datasource.dart | 2 +- .../data/models/community_status_model.dart | 21 + .../domain/entities/psychologist_entity.dart | 6 +- .../screens/dass_assessment_screen.dart | 1635 +++-------------- .../widgets/dass_floating_save_bar.dart | 141 ++ .../widgets/dass_question_card.dart | 311 ++++ .../widgets/dass_subscale_info_sheet.dart | 430 +++++ .../widgets/dass_subscales_card.dart | 275 +++ .../datasources/diary_fallback_fixture.dart | 112 ++ .../screens/ai_diary_detail_screen.dart | 1568 +++------------- .../diary_emotion_distribution_card.dart | 131 ++ .../widgets/diary_insight_card.dart | 171 ++ .../widgets/diary_key_events_card.dart | 86 + .../widgets/diary_reflection_card.dart | 65 + .../widgets/diary_transcript_section.dart | 257 +++ .../presentation/screens/home_screen.dart | 1135 +----------- .../widgets/home_crisis_banner.dart | 123 ++ .../widgets/home_daily_progress_card.dart | 361 ++++ .../widgets/home_eva_insight_card.dart | 87 + .../widgets/home_quick_actions.dart | 138 ++ .../widgets/home_recommendations_section.dart | 339 ++++ .../interactive_spotlight_overlay.dart | 51 +- .../test/features/care/care_screen_test.dart | 10 +- .../tutorial/main_shell_tutorial_test.dart | 2 +- .../main_shell_and_call_screen_test.dart | 3 +- .../test/widgets/app_back_button_test.dart | 3 +- 26 files changed, 3690 insertions(+), 3773 deletions(-) create mode 100644 apps/eva_mobile/lib/features/dass/presentation/widgets/dass_floating_save_bar.dart create mode 100644 apps/eva_mobile/lib/features/dass/presentation/widgets/dass_question_card.dart create mode 100644 apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscale_info_sheet.dart create mode 100644 apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscales_card.dart create mode 100644 apps/eva_mobile/lib/features/diary/data/datasources/diary_fallback_fixture.dart create mode 100644 apps/eva_mobile/lib/features/diary/presentation/widgets/diary_emotion_distribution_card.dart create mode 100644 apps/eva_mobile/lib/features/diary/presentation/widgets/diary_insight_card.dart create mode 100644 apps/eva_mobile/lib/features/diary/presentation/widgets/diary_key_events_card.dart create mode 100644 apps/eva_mobile/lib/features/diary/presentation/widgets/diary_reflection_card.dart create mode 100644 apps/eva_mobile/lib/features/diary/presentation/widgets/diary_transcript_section.dart create mode 100644 apps/eva_mobile/lib/features/home/presentation/widgets/home_crisis_banner.dart create mode 100644 apps/eva_mobile/lib/features/home/presentation/widgets/home_daily_progress_card.dart create mode 100644 apps/eva_mobile/lib/features/home/presentation/widgets/home_eva_insight_card.dart create mode 100644 apps/eva_mobile/lib/features/home/presentation/widgets/home_quick_actions.dart create mode 100644 apps/eva_mobile/lib/features/home/presentation/widgets/home_recommendations_section.dart diff --git a/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart b/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart index 7e7432b..7e7a247 100644 --- a/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart +++ b/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart @@ -214,7 +214,7 @@ class MockCareDataSource implements CareDataSource { vipRemainingDays: 30, joinedAt: DateTime.now(), memberCount: _communityStatus.memberCount + 1, - ) as CommunityStatusModel; + ); return _communityStatus; } diff --git a/apps/eva_mobile/lib/features/care/data/models/community_status_model.dart b/apps/eva_mobile/lib/features/care/data/models/community_status_model.dart index c3e7c6a..f3b4d06 100644 --- a/apps/eva_mobile/lib/features/care/data/models/community_status_model.dart +++ b/apps/eva_mobile/lib/features/care/data/models/community_status_model.dart @@ -25,6 +25,27 @@ class CommunityStatusModel extends CommunityStatusEntity { ); } + @override + CommunityStatusModel copyWith({ + bool? isMember, + bool? isVipActive, + int? vipRemainingDays, + DateTime? joinedAt, + String? serverName, + String? inviteUrl, + int? memberCount, + }) { + return CommunityStatusModel( + isMember: isMember ?? this.isMember, + isVipActive: isVipActive ?? this.isVipActive, + vipRemainingDays: vipRemainingDays ?? this.vipRemainingDays, + joinedAt: joinedAt ?? this.joinedAt, + serverName: serverName ?? this.serverName, + inviteUrl: inviteUrl ?? this.inviteUrl, + memberCount: memberCount ?? this.memberCount, + ); + } + Map toJson() { return { 'is_member': isMember, diff --git a/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart b/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart index 97f9256..cfa3e49 100644 --- a/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart +++ b/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart @@ -34,5 +34,9 @@ class PsychologistEntity { required this.bio, }); - bool get isPsychiatrist => title.toLowerCase().contains('sp.kj'); + bool get isPsychiatrist => + title.toLowerCase().contains('sp.kj') || + title.toLowerCase().contains('kedokteran jiwa') || + title.toLowerCase().contains('psikiater') || + name.toLowerCase().contains('sp.kj'); } diff --git a/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart b/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart index fea4bc4..2fc487a 100644 --- a/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart +++ b/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart @@ -3,18 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:eva_mobile/core/utils/responsive_layout_helper.dart'; -import 'package:eva_mobile/features/dass/domain/entities/dass_assessment_entity.dart'; -import 'package:eva_mobile/features/dass/presentation/providers/dass_provider.dart'; -import 'package:eva_mobile/features/dass/presentation/widgets/bouncing_button.dart'; -import 'package:eva_mobile/features/dass/presentation/widgets/dass_ai_analysis_accordion.dart'; -import 'package:eva_mobile/features/dass/presentation/widgets/dass_skeleton_loader.dart'; -import 'package:eva_mobile/theme/app_colors.dart'; -import 'package:eva_mobile/widgets/app_back_button.dart'; -import 'package:eva_mobile/widgets/app_snack_bar.dart'; -import 'package:eva_mobile/widgets/glass_card.dart'; -import 'package:eva_mobile/widgets/staggered_entrance.dart'; - +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../../../widgets/app_snack_bar.dart'; +import '../../../../widgets/staggered_entrance.dart'; +import '../providers/dass_provider.dart'; +import '../widgets/dass_floating_save_bar.dart'; +import '../widgets/dass_question_card.dart'; +import '../widgets/dass_skeleton_loader.dart'; +import '../widgets/dass_subscale_info_sheet.dart'; +import '../widgets/dass_subscales_card.dart'; + +/// Screen for displaying, reviewing, and verifying DASS-21 psychological self-assessments +/// with period filtering, explainable AI accordion insights, and 3-subscale evaluations. class DASSAssessmentScreen extends ConsumerStatefulWidget { final String initialPeriod; @@ -32,54 +33,36 @@ class _DASSAssessmentScreenState extends ConsumerState { String _selectedFilter = 'all'; // 'all', 'stress', 'anxiety', 'depression' final Set _expandedItemIds = {}; + static const String _whatsappEmergencyUrl = + 'https://api.whatsapp.com/send/?phone=6281380073120&text=halo%20kak%2C%20saya%20ingin%20bercerita%20mengenai...&type=phone_number&app_absent=0'; + @override void initState() { super.initState(); _currentPeriod = widget.initialPeriod; } - 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'; + 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) { + AppSnackBar.showError( + context, + 'Tidak dapat membuka WhatsApp Konseling Krisis', + ); + } + } } } - 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); @@ -92,305 +75,209 @@ class _DASSAssessmentScreenState extends ConsumerState { 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 + ? currentAssessment.items + .where((it) => (staged[it.itemId] ?? it.score) != it.score) + .length : 0; final bool isUnverified = currentAssessment != null && !currentAssessment.verifiedByUser; - final bool showSaveBar = isEditable && currentAssessment != null && (hasChanges || isUnverified); + final 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(), + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ), - - // 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, + ), + child: SafeArea( + child: Column( + children: [ + StaggeredEntrance( + index: 0, + child: _buildTopBar(context, notifier), + ), + StaggeredEntrance( + index: 1, + child: _buildPeriodSelector(), + ), + 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 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: [ - 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), + StaggeredEntrance( + index: 2, + child: _buildModeBanner(isEditable), + ), + const SizedBox(height: 16), + StaggeredEntrance( + index: 3, + child: DassSubscalesCard( + assessment: assessment, + stagedScores: staged, + onInfoTap: () => DassSubscaleInfoSheet.showGuideDialog(context), + onSubscaleTap: (label, score, severityIndo, scaleType) { + DassSubscaleInfoSheet.show( + context: context, + label: label, + score: score, + severityIndo: severityIndo, + scaleType: scaleType, + scoreColor: DassSubscalesCard.getScoreColor(severityIndo), + ); + }, + onExtremelySevereTap: (label, score, scaleType) { + DassSubscaleInfoSheet.showPsychologistReferral( + context: context, + label: label, + score: score, + scaleType: scaleType, + onOpenWhatsapp: _openWhatsappEmergency, + ); + }, ), ), - const SizedBox(height: 6), - Text( - '$err', - style: GoogleFonts.plusJakartaSans(fontSize: 12, color: Colors.redAccent), - textAlign: TextAlign.center, + const SizedBox(height: 16), + StaggeredEntrance( + index: 4, + child: _buildFilterChips(assessment.items.length), ), 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)), + StaggeredEntrance( + index: 5, + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: filteredItems.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final item = filteredItems[index]; + final currentScore = staged[item.itemId] ?? item.score; + return DassQuestionCard( + item: item, + currentScore: currentScore, + isEditable: isEditable, + isExpanded: _expandedItemIds.contains(item.itemId), + onToggleExpand: () { + setState(() { + if (_expandedItemIds.contains(item.itemId)) { + _expandedItemIds.remove(item.itemId); + } else { + _expandedItemIds.add(item.itemId); + } + }); + }, + onScoreChanged: (newScore) => + notifier.updateScore(item.itemId, newScore), + onUndoAI: () => notifier.updateScore(item.itemId, item.score), + ); + }, ), ), + const SizedBox(height: 80), ], ), - ), - ], + ); + }, ), ), - 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) { - AppSnackBar.showWithMessenger( - scaffoldMessenger, - message: - 'Asesmen DASS-21 hari ini berhasil diverifikasi dan disimpan!', - variant: SnackBarVariant.success, - ); - } - }, - 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, - ), - ), - ), - ], ), ), - ), - ), + DassFloatingSaveBar( + showSaveBar: showSaveBar, + hasChanges: hasChanges, + isUnverified: isUnverified, + changedCount: changedCount, + isSaving: dassState.isSaving, + onSave: () async { + final scaffoldMessenger = ScaffoldMessenger.of(context); + final success = await notifier.saveAssessment(); + if (!mounted) return; + if (success) { + AppSnackBar.showWithMessenger( + scaffoldMessenger, + message: 'Asesmen DASS-21 hari ini berhasil diverifikasi dan disimpan!', + variant: SnackBarVariant.success, + ); + } + }, + ), + ], ), ); } @@ -564,50 +451,23 @@ class _DASSAssessmentScreenState extends ConsumerState { 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), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: const Color(0xFFE8F0FE), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFF1A73E8).withValues(alpha: 0.3)), + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFBFDBFE)), ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.lock_outline_rounded, color: Color(0xFF1A73E8), size: 20), - const SizedBox(width: 8), + const Icon(Icons.edit_note_rounded, color: Color(0xFF2563EB), size: 20), + const SizedBox(width: 10), Expanded( child: Text( - 'Mode Riwayat Agregat (Hanya Baca): Menampilkan akumulasi rata-rata indikator pada rentang ini. Pengeditan hanya dapat dilakukan pada asesmen Hari Ini.', + 'Mode Koreksi Mandiri: Anda dapat menyesuaikan skor DASS-21 hasil sintesis AI hari ini.', style: GoogleFonts.plusJakartaSans( - fontSize: 12, - color: const Color(0xFF1A73E8), + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF1E40AF), height: 1.35, ), ), @@ -616,89 +476,27 @@ class _DASSAssessmentScreenState extends ConsumerState { ), ); } - } - - 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, + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( children: [ - 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 Icon(Icons.history_rounded, color: Color(0xFF64748B), size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Arsip Historis: Data asesmen hari sebelumnya tersimpan secara permanen untuk evaluasi klinis.', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF475569), + height: 1.35, ), - ], - ), - 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', - ), - ], ), ), ], @@ -706,936 +504,57 @@ class _DASSAssessmentScreenState extends ConsumerState { ); } - 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)), - ], - ), - ), + _buildChip('all', 'Semua ($totalCount)'), + const SizedBox(width: 8), + _buildChip('stress', 'Stres (7)'), + const SizedBox(width: 8), + _buildChip('anxiety', 'Kecemasan (7)'), + const SizedBox(width: 8), + _buildChip('depression', 'Depresi (7)'), ], ), ); } - Widget _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( + Widget _buildChip(String filterKey, String label) { + final isSelected = _selectedFilter == filterKey; + return GestureDetector( + onTap: () { + setState(() { + _selectedFilter = filterKey; + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF1E1B4B) : Colors.white, borderRadius: BorderRadius.circular(20), - 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 DassAIAnalysisAccordion( - isExpanded: isExpanded, - item: item, - confBg: confBg, - confColor: confColor, - confPercent: confPercent, - confLabel: confLabel, - hasEvidence: hasEvidence, - hasReason: hasReason, - ); - } - - Widget _buildLikertButton( - int score, - String label, - bool isSelected, - 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), - ), - ), - ), + border: Border.all( + color: isSelected ? const Color(0xFF1E1B4B) : const Color(0xFFE2E8F0), ), - ), - ), - ); - } - - 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'), - ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 4, + offset: const Offset(0, 1), ), ], ), - ), - ); - } - - 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, - ), - ), - ), - ), - ], + child: Text( + label, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected ? Colors.white : const Color(0xFF64748B), + ), ), ), ); } - - 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) { - AppSnackBar.showError( - context, - 'Tidak dapat membuka WhatsApp Konseling Krisis', - ); - } - } - } - } - - } diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_floating_save_bar.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_floating_save_bar.dart new file mode 100644 index 0000000..027b7d8 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_floating_save_bar.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; + +/// Floating bottom action bar displaying verification/save status and assessment save button. +class DassFloatingSaveBar extends StatelessWidget { + final bool showSaveBar; + final bool hasChanges; + final bool isUnverified; + final int changedCount; + final bool isSaving; + final VoidCallback onSave; + + const DassFloatingSaveBar({ + super.key, + required this.showSaveBar, + required this.hasChanges, + required this.isUnverified, + required this.changedCount, + required this.isSaving, + required this.onSave, + }); + + @override + Widget build(BuildContext context) { + final String titleText = hasChanges + ? '$changedCount Butir Diubah' + : (isUnverified ? 'Sintesis AI Siap' : 'Asesmen Tersimpan'); + final String subtitleText = hasChanges + ? 'Simpan koreksi asesmen' + : (isUnverified ? 'Simpan asesmen hari ini' : 'Skor telah terverifikasi'); + const String buttonText = 'Simpan'; + final IconData barIcon = + hasChanges ? Icons.edit_note_rounded : Icons.check_circle_outline_rounded; + + return Positioned( + left: 16, + right: 16, + bottom: 16, + child: IgnorePointer( + ignoring: !showSaveBar, + child: AnimatedSlide( + offset: showSaveBar ? Offset.zero : const Offset(0, 2.0), + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: showSaveBar ? 1.0 : 0.0, + duration: const Duration(milliseconds: 250), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: const Color(0xFF1E1B4B), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1E1B4B).withValues(alpha: 0.35), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + barIcon, + color: Colors.white, + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + titleText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 2), + Text( + subtitleText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: Colors.white.withValues(alpha: 0.75), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: isSaving ? null : onSave, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : Text( + buttonText, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_question_card.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_question_card.dart new file mode 100644 index 0000000..4675f0e --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_question_card.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; +import '../../domain/entities/dass_assessment_entity.dart'; +import 'bouncing_button.dart'; +import 'dass_ai_analysis_accordion.dart'; + +/// Card displaying an individual DASS-21 question with 4-point Likert buttons and explainable AI insights. +class DassQuestionCard extends StatelessWidget { + final DASSItemEntity item; + final int currentScore; + final bool isEditable; + final bool isExpanded; + final VoidCallback onToggleExpand; + final ValueChanged onScoreChanged; + final VoidCallback onUndoAI; + + const DassQuestionCard({ + super.key, + required this.item, + required this.currentScore, + required this.isEditable, + required this.isExpanded, + required this.onToggleExpand, + required this.onScoreChanged, + required this.onUndoAI, + }); + + static Color getScaleColor(String scale) { + switch (scale) { + case 'stress': + return const Color(0xFFF57C00); + case 'anxiety': + return const Color(0xFF7C3AED); + case 'depression': + default: + return const Color(0xFF2563EB); + } + } + + static String getScaleLabel(String scale) { + switch (scale) { + case 'stress': + return 'Stres'; + case 'anxiety': + return 'Kecemasan'; + case 'depression': + default: + return 'Depresi'; + } + } + + static Color getConfidenceColor(double conf) { + if (conf >= 0.8) return const Color(0xFF16A34A); + if (conf >= 0.5) return const Color(0xFFD97706); + return const Color(0xFFDC2626); + } + + static Color getConfidenceBg(double conf) { + if (conf >= 0.8) return const Color(0xFFF0FDF4); + if (conf >= 0.5) return const Color(0xFFFFFBEB); + return const Color(0xFFFEF2F2); + } + + static String getConfidenceLabel(double conf) { + if (conf >= 0.8) return 'Tinggi'; + if (conf >= 0.5) return 'Sedang'; + return 'Rendah'; + } + + @override + Widget build(BuildContext context) { + final scaleColor = getScaleColor(item.scale); + final scaleLabel = getScaleLabel(item.scale); + final isAnsweredByAI = item.confidence > 0 || + (item.evidence != null && item.evidence!.trim().isNotEmpty) || + (item.reason != null && item.reason!.trim().isNotEmpty); + + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Item Number & Subscale Tag + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 6, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: scaleColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Item ${item.itemId} • $scaleLabel', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: scaleColor, + ), + ), + ), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 4, + children: [ + if (item.isUserEdited || (isEditable && item.score != currentScore)) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Telah Dikoreksi', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w600, + color: const Color(0xFF4F46E5), + ), + ), + ), + if (isEditable && item.score != currentScore && isAnsweredByAI) + GestureDetector( + onTap: onUndoAI, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.undo_rounded, size: 10, color: Color(0xFF4B5563)), + const SizedBox(width: 3), + Text( + 'Saran AI (${item.score})', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w600, + color: const Color(0xFF4B5563), + ), + ), + ], + ), + ), + ), + if (isAnsweredByAI) _buildAIAnalysisIconButton(), + ], + ), + ], + ), + const SizedBox(height: 8), + + // Question Text + Text( + item.questionText, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w600, + color: const Color(0xFF1F2937), + height: 1.35, + ), + ), + + // Explainable AI Accordion (Evidence, Confidence, Reason) + if (isAnsweredByAI) _buildAIAnalysisSection(), + + const SizedBox(height: 12), + + // Likert Selector Options (0 - 3) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + _buildLikertButton(0, '0: Tdk Ada', currentScore == 0), + const SizedBox(width: 6), + _buildLikertButton(1, '1: Kadang', currentScore == 1), + const SizedBox(width: 6), + _buildLikertButton(2, '2: Sering', currentScore == 2), + const SizedBox(width: 6), + _buildLikertButton(3, '3: Selalu', currentScore == 3), + ], + ), + ), + ], + ), + ); + } + + Widget _buildAIAnalysisIconButton() { + final conf = item.confidence; + final confColor = getConfidenceColor(conf); + final confBg = getConfidenceBg(conf); + final confPercent = (conf * 100).round(); + + return Tooltip( + message: isExpanded + ? 'Tutup Analisis AI' + : (conf > 0 ? 'Analisis Eva ($confPercent% Yakin)' : 'Lihat Analisis Eva'), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: onToggleExpand, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: isExpanded ? confBg : const Color(0xFFF1F5F9), + shape: BoxShape.circle, + border: Border.all( + color: isExpanded ? confColor.withValues(alpha: 0.4) : const Color(0xFFE2E8F0), + ), + ), + child: Icon( + Icons.auto_awesome_rounded, + size: 15, + color: isExpanded ? confColor : const Color(0xFF6366F1), + ), + ), + ), + ), + ); + } + + Widget _buildAIAnalysisSection() { + final hasEvidence = item.evidence != null && item.evidence!.trim().isNotEmpty; + final hasReason = item.reason != null && item.reason!.trim().isNotEmpty; + final conf = item.confidence; + final confPercent = (conf * 100).round(); + final confColor = getConfidenceColor(conf); + final confBg = getConfidenceBg(conf); + final confLabel = getConfidenceLabel(conf); + + return DassAIAnalysisAccordion( + isExpanded: isExpanded, + item: item, + confBg: confBg, + confColor: confColor, + confPercent: confPercent, + confLabel: confLabel, + hasEvidence: hasEvidence, + hasReason: hasReason, + ); + } + + Widget _buildLikertButton(int score, String label, bool isSelected) { + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: BouncingButton( + onTap: isEditable ? () => onScoreChanged(score) : null, + scaleFactor: 0.92, + child: AnimatedScale( + scale: isSelected ? 1.04 : 1.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeInOut, + padding: const EdgeInsets.symmetric(vertical: 9), + decoration: BoxDecoration( + color: isSelected + ? AppColors.primary + : (isEditable ? Colors.white : const Color(0xFFF3F4F6)), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppColors.primary + : (isEditable ? const Color(0xFFD1D5DB) : const Color(0xFFE5E7EB)), + width: isSelected ? 1.8 : 1.0, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.28), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: isSelected ? FontWeight.w800 : FontWeight.w500, + color: isSelected + ? Colors.white + : (isEditable ? const Color(0xFF4B5563) : const Color(0xFF9CA3AF)), + ), + child: Text(label), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscale_info_sheet.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscale_info_sheet.dart new file mode 100644 index 0000000..33d0421 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscale_info_sheet.dart @@ -0,0 +1,430 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; + +/// Modal bottom sheets presenting DASS-21 subscale indicator explanations and clinical psychologist referral. +class DassSubscaleInfoSheet { + static void show({ + required BuildContext context, + required String label, + required int score, + required String severityIndo, + required String scaleType, + required Color scoreColor, + }) { + String definition = ''; + String tip = ''; + + if (scaleType == 'depression') { + definition = + 'Subskala Depresi mengukur tingkat suasana hati disforik, hilangnya motivasi/inisiatif, anhedonia (kesulitan merasakan hal positif), dan perasaan ketidakberhargaan.'; + tip = + 'Coba lakukan aktivitas kecil yang kamu sukai, luapkan pikiran melalui percakapan bersama EVA, atau jalan santai menghirup udara segar.'; + } else if (scaleType == 'anxiety') { + definition = + 'Subskala Kecemasan mengukur stimulasi otonomik (otot tegang, detak jantung kencang), cemas situasional, dan ketakutan akan kehilangan kendali.'; + tip = + 'Lakukan teknik pernapasan 4-7-8 atau relaksasi otot bertahap untuk menurunkan stimulasi fisik berlebih.'; + } else { + definition = + 'Subskala Stres mengukur ketegangan syaraf non-spesifik, reaksi berlebihan pada masalah harian, kegelisahan, dan ketidakmampuan untuk bersantai.'; + tip = + 'Ambil jeda istirahat dari pekerjaan/tugas, hindari multitasking berlebih, dan nikmati minuman hangat.'; + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: scoreColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label.toUpperCase(), + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: scoreColor, + ), + ), + ), + const Spacer(), + Text( + '$score / 42 Poin', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Text( + 'Tingkat Keparahan: ', + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textSecondary), + ), + Text( + severityIndo, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: scoreColor, + ), + ), + ], + ), + const SizedBox(height: 14), + const Divider(height: 1, color: Color(0xFFEADBFF)), + const SizedBox(height: 14), + Text( + 'PENJELASAN INDIKATOR:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + Text( + definition, + style: GoogleFonts.inter(fontSize: 13, color: AppColors.textPrimary, height: 1.45), + ), + const SizedBox(height: 14), + Text( + 'REKOMENDASI MANDIRI:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Text( + tip, + style: GoogleFonts.inter(fontSize: 12.5, color: AppColors.textPrimary, height: 1.4), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Tutup'), + ), + ), + ], + ), + ), + ); + } + + static void showPsychologistReferral({ + required BuildContext context, + required String label, + required int score, + required String scaleType, + required VoidCallback onOpenWhatsapp, + }) { + String detailExplanation = ''; + + if (scaleType == 'depression') { + detailExplanation = + 'Sistem mencatat tingkat Depresi pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini dicirikan oleh anhedonia total (kehilangan kemampuan merasakan hal positif), perasaan tidak berharga mendalam, serta keletihan fisik dan emosional yang signifikan.\n\n' + '⚠️ Catatan Penting: Eva AI adalah pendamping mandiri dan bukan pengganti diagnosis atau terapi medis klinis. Kamu sangat disarankan untuk berbicara langsung dengan Psikolog Klinis atau Psikiater.'; + } else if (scaleType == 'anxiety') { + detailExplanation = + 'Sistem mencatat tingkat Kecemasan pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini ditandai dengan serangan panik intens, detak jantung kencang tanpa alasan fisik, guncangan rasa takut akut, atau kekhawatiran melumpuhkan harian.\n\n' + '⚠️ Catatan Penting: Gejala kecemasan akut membutuhkan penanganan profesional medis. Sangat disarankan untuk segera menemui Psikolog Klinis atau Psikiater terdekat.'; + } else { + detailExplanation = + 'Sistem mencatat tingkat Stres pengguna pada kategori Sangat Berat (Extremely Severe). Kondisi ini mengindikasikan burnout ekstrem, ketegangan saraf kronis, kelelahan energi berat, serta keputusasaan dalam menangani tekanan harian.\n\n' + '⚠️ Catatan Penting: Beban stres kronis dapat berdampak pada kesehatan fisik. Disarankan untuk mengambil jeda penuh dan melakukan sesi konseling bersama psikolog profesional.'; + } + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFEBEE), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFD1D5DB)), + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Color(0xFFD32F2F), size: 28), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SANGAT BERAT (EXTREMELY SEVERE)', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFFD32F2F), + letterSpacing: 0.5, + ), + ), + Text( + 'Indikasi $label: $score / 42 Poin', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: const Color(0xFFB71C1C), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + 'PENJELASAN KLINIS & REKOMENDASI:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Text( + detailExplanation, + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.5, + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(ctx); + onOpenWhatsapp(); + }, + icon: const Icon(Icons.chat_outlined, size: 20), + label: Text( + 'Konsultasi via WhatsApp Psikolog / Krisis', + style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF25D366), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text( + 'Tutup Penjelasan', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ), + ], + ), + ), + ); + } + + static void showGuideDialog(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + side: BorderSide(color: Color(0xFFD1D5DB), width: 1), + ), + builder: (ctx) => SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Icon(Icons.info_outline, color: AppColors.primary, size: 22), + const SizedBox(width: 8), + Text( + 'Panduan Skala DASS-21', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'DASS-21 (Depression Anxiety Stress Scales) mengukur 3 indikator psikologis harian (0–42 poin per subskala) dari transkrip percakapanmu bersama Eva AI.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.45, + ), + ), + const SizedBox(height: 16), + Text( + 'KATEGORI WARNA & TINGKAT KEPARAHAN:', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + _buildLegendRow('Normal', 'Kondisi emosi stabil & terkendali.', const Color(0xFF2E7D32)), + _buildLegendRow('Ringan (Mild)', 'Fluktuasi emosi ringan harian.', const Color(0xFFFBC02D)), + _buildLegendRow('Sedang (Moderate)', 'Mulai mengganggu kenyamanan & butuh jeda istirahat.', const Color(0xFFF57C00)), + _buildLegendRow('Berat (Severe)', 'Beban emosional terasa berat & perlu perhatian khusus.', const Color(0xFFE65100)), + _buildLegendRow('Sangat Berat (Extremely Severe)', 'Indikasi krisis — sangat disarankan konsultasi ke psikolog/psikiater.', const Color(0xFFD32F2F)), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Saya Mengerti'), + ), + ), + ], + ), + ), + ), + ); + } + + static Widget _buildLegendRow(String label, String desc, Color color) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 4), + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Expanded( + child: RichText( + text: TextSpan( + style: GoogleFonts.inter(fontSize: 12, color: AppColors.textPrimary, height: 1.35), + children: [ + TextSpan( + text: '$label: ', + style: TextStyle(fontWeight: FontWeight.w700, color: color), + ), + TextSpan(text: desc), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscales_card.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscales_card.dart new file mode 100644 index 0000000..7a8a526 --- /dev/null +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_subscales_card.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../core/utils/responsive_layout_helper.dart'; +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; +import '../../domain/entities/dass_assessment_entity.dart'; +import 'bouncing_button.dart'; + +/// Card rendering summary scores for the 3 DASS-21 subscales (Stress, Anxiety, Depression). +class DassSubscalesCard extends StatelessWidget { + final DASSAssessmentEntity assessment; + final Map stagedScores; + final VoidCallback onInfoTap; + final void Function(String label, int score, String severityIndo, String scaleType) onSubscaleTap; + final void Function(String label, int score, String scaleType) onExtremelySevereTap; + + const DassSubscalesCard({ + super.key, + required this.assessment, + required this.stagedScores, + required this.onInfoTap, + required this.onSubscaleTap, + required this.onExtremelySevereTap, + }); + + static String getIndonesianSeverity(String scale, int score) { + if (scale == 'depression') { + if (score >= 28) return 'Sangat Berat'; + if (score >= 21) return 'Berat'; + if (score >= 14) return 'Sedang'; + if (score >= 10) return 'Ringan'; + return 'Normal'; + } else if (scale == 'anxiety') { + if (score >= 20) return 'Sangat Berat'; + if (score >= 15) return 'Berat'; + if (score >= 10) return 'Sedang'; + if (score >= 8) return 'Ringan'; + return 'Normal'; + } else { + if (score >= 34) return 'Sangat Berat'; + if (score >= 26) return 'Berat'; + if (score >= 19) return 'Sedang'; + if (score >= 15) return 'Ringan'; + return 'Normal'; + } + } + + static Color getScoreColor(String severityIndo) { + switch (severityIndo) { + case 'Sangat Berat': + return const Color(0xFFD32F2F); + case 'Berat': + return const Color(0xFFE65100); + case 'Sedang': + return const Color(0xFFF57C00); + case 'Ringan': + return const Color(0xFFFBC02D); + case 'Normal': + default: + return const Color(0xFF2E7D32); + } + } + + @override + Widget build(BuildContext context) { + int strRaw = 0, anxRaw = 0, depRaw = 0; + + for (final item in assessment.items) { + final s = stagedScores[item.itemId] ?? item.score; + if (item.scale == 'stress') strRaw += s; + if (item.scale == 'anxiety') anxRaw += s; + if (item.scale == 'depression') depRaw += s; + } + + final int stressScore = strRaw * 2; + final int anxietyScore = anxRaw * 2; + final int depressionScore = depRaw * 2; + + final stressSev = getIndonesianSeverity('stress', stressScore); + final anxietySev = getIndonesianSeverity('anxiety', anxietyScore); + final depressionSev = getIndonesianSeverity('depression', depressionScore); + + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Skor 3 Dimensi DASS-21', + style: GoogleFonts.plusJakartaSans( + fontSize: context.responsiveFont(14), + fontWeight: FontWeight.w700, + color: const Color(0xFF1E1B4B), + ), + ), + const SizedBox(width: 8), + InkWell( + onTap: onInfoTap, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Color(0xFFE8F0FE), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.info_outline, + size: 15, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + _buildSubscaleTile( + context, + 'Stres', + stressScore, + stressSev, + 'stress', + ), + const SizedBox(width: 8), + _buildSubscaleTile( + context, + 'Kecemasan', + anxietyScore, + anxietySev, + 'anxiety', + ), + const SizedBox(width: 8), + _buildSubscaleTile( + context, + 'Depresi', + depressionScore, + depressionSev, + 'depression', + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSubscaleTile( + BuildContext context, + String label, + int score, + String severityIndo, + String scaleType, + ) { + final scoreColor = getScoreColor(severityIndo); + final bool isExtremelySevere = severityIndo == 'Sangat Berat'; + + final Color bgColor = isExtremelySevere + ? const Color(0xFFD32F2F) + : scoreColor.withValues(alpha: 0.08); + + final Color labelColor = isExtremelySevere ? Colors.white70 : scoreColor; + + return Expanded( + child: BouncingButton( + onTap: () { + if (isExtremelySevere) { + onExtremelySevereTap(label, score, scaleType); + } else { + onSubscaleTap(label, score, severityIndo, scaleType); + } + }, + scaleFactor: 0.94, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isExtremelySevere + ? const Color(0xFFB71C1C) + : scoreColor.withValues(alpha: 0.25), + width: isExtremelySevere ? 1.5 : 1, + ), + boxShadow: isExtremelySevere + ? [ + BoxShadow( + color: const Color(0xFFD32F2F).withValues(alpha: 0.35), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isExtremelySevere) ...[ + const Icon(Icons.warning_amber_rounded, size: 12, color: Colors.white), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: labelColor, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$score', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w900, + color: isExtremelySevere ? Colors.white : scoreColor, + ), + ), + Text( + '/42', + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w600, + color: isExtremelySevere ? Colors.white70 : AppColors.textLight, + ), + ), + ], + ), + const SizedBox(height: 2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: isExtremelySevere + ? Colors.white.withValues(alpha: 0.2) + : scoreColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + severityIndo, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 8.5, + fontWeight: FontWeight.w800, + color: isExtremelySevere ? Colors.white : scoreColor, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/data/datasources/diary_fallback_fixture.dart b/apps/eva_mobile/lib/features/diary/data/datasources/diary_fallback_fixture.dart new file mode 100644 index 0000000..c254537 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/data/datasources/diary_fallback_fixture.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +/// Fallback mock dataset for AI Diary Detail screen when no arguments or data are provided. +final Map kDefaultDiaryDetailFallback = { + 'title': 'Refleksi Harian & Evaluasi Ujian', + 'date': '24 Oktober 2023', + 'sessionCount': 3, + 'lastSessionTime': '21:45 PM', + 'moodTag': 'Cemas & Stres', + 'moodEmoji': '😰', + 'summary': + 'Kumulatif 3 sesi suara hari ini: Refleksi Pagi (kecemasan akademik), Curhat Sore (istirahat teh), dan Refleksi Malam (evaluasi jadwal). EVA menyintesis kemajuan emosional harianmu.', + 'riskWarning': { + 'detected': true, + 'type': 'anxiety', + 'title': 'Indikasi Anxiety & Stres Kumulatif Terdeteksi', + 'level': 'Tinggi (68%)', + 'message': + 'EVA mendeteksi akumulasi kecemasan dan stres pada 3 sesi percakapan hari ini. Jangan ragu mengambil waktu jeda istirahat.', + }, + 'aiInsight': + 'Kumulatif Hari Ini: Meskipun beban akademik memicu akumulasi rasa cemas pada Sesi #1 dan Sesi #3, kamu berhasil menenangkan diri pada Sesi #2 saat istirahat teh.', + 'importantEvents': [ + '[Sesi #1 - 09:15 AM] Sesi belajar pagi & kecemasan ujian tengah semester', + '[Sesi #2 - 16:30 PM] Minum teh hangat & jeda santai', + '[Sesi #3 - 21:45 PM] Mengatur ulang target jadwal perkuliahan', + ], + 'emotionalReflection': + 'Dinamika harian menunjukkan fluktuasi dari kecemasan tinggi di pagi hari, mereda di sore hari, dan kembali reflektif di malam hari.', + 'sessions': [ + { + 'id': 's1', + 'title': 'Sesi #1: Refleksi Pagi & Ujian', + 'time': '09:15 AM', + 'moodTag': 'Takut & Cemas', + 'moodEmoji': '😨', + 'emotionsBreakdown': [ + {'name': 'fear', 'label': 'Takut / Gelisah', 'emoji': '😨', 'percent': 0.65, 'color': const Color(0xFF6C63FF)}, + {'name': 'sadness', 'label': 'Sedih / Haru', 'emoji': '😔', 'percent': 0.20, 'color': const Color(0xFF8B93FF)}, + {'name': 'netral', 'label': 'Netral', 'emoji': '😐', 'percent': 0.15, 'color': const Color(0xFFA7E6FF)}, + ], + 'transcripts': [ + { + 'isUser': true, + 'time': '09:15 AM', + 'text': 'Saya sangat cemas dan takut tidak bisa menyelesaikan tugas kuliah ini dengan baik.', + 'emotionTag': 'fear (68%)', + 'emotionEmoji': '😨', + }, + { + 'isUser': false, + 'time': '09:16 AM', + 'text': + 'Aku mendengarmu, Sarah. Sangat wajar merasa cemas saat tugas menumpuk. Mari kita uraikan bersama menjadi langkah kecil ya.', + }, + ], + }, + { + 'id': 's2', + 'title': 'Sesi #2: Jeda Ketenangan Sore', + 'time': '16:30 PM', + 'moodTag': 'Tenang & Nyaman', + 'moodEmoji': '😌', + 'emotionsBreakdown': [ + {'name': 'netral', 'label': 'Netral', 'emoji': '😐', 'percent': 0.50, 'color': const Color(0xFFA7E6FF)}, + {'name': 'happy', 'label': 'Bahagia', 'emoji': '😃', 'percent': 0.35, 'color': const Color(0xFFFFE6A7)}, + {'name': 'fear', 'label': 'Takut / Gelisah', 'emoji': '😨', 'percent': 0.15, 'color': const Color(0xFF6C63FF)}, + ], + 'transcripts': [ + { + 'isUser': true, + 'time': '16:30 PM', + 'text': 'Saya baru saja minum teh dan berjalan santai sebentar. Rasanya sedikit lebih lega.', + 'emotionTag': 'netral (60%)', + 'emotionEmoji': '😐', + }, + { + 'isUser': false, + 'time': '16:31 PM', + 'text': + 'Itu langkah yang luar biasa! Memberikan waktu istirahat pada pikiran sangat penting untuk pemulihan energimu.', + }, + ], + }, + { + 'id': 's3', + 'title': 'Sesi #3: Evaluasi Malam & Jadwal', + 'time': '21:45 PM', + 'moodTag': 'Reflektif & Lelah', + 'moodEmoji': '😴', + 'emotionsBreakdown': [ + {'name': 'fear', 'label': 'Takut / Gelisah', 'emoji': '😨', 'percent': 0.40, 'color': const Color(0xFF6C63FF)}, + {'name': 'sadness', 'label': 'Sedih / Haru', 'emoji': '😔', 'percent': 0.35, 'color': const Color(0xFF8B93FF)}, + {'name': 'netral', 'label': 'Netral', 'emoji': '😐', 'percent': 0.25, 'color': const Color(0xFFA7E6FF)}, + ], + 'transcripts': [ + { + 'isUser': true, + 'time': '21:45 PM', + 'text': 'Malam ini saya merapikan ulang target besok agar tidak kaget lagi.', + 'emotionTag': 'sadness (55%)', + 'emotionEmoji': '😔', + }, + { + 'isUser': false, + 'time': '21:46 PM', + 'text': 'Langkah yang sangat bijak. Sekarang matikan gawai dan istirahatlah dengan tenang.', + }, + ], + }, + ], +}; diff --git a/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_detail_screen.dart b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_detail_screen.dart index 73ed017..5241a01 100644 --- a/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_detail_screen.dart +++ b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_detail_screen.dart @@ -1,33 +1,29 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:eva_mobile/features/chat/presentation/providers/chat_provider.dart'; -import 'package:eva_mobile/features/diary/data/models/diary_entry_model.dart'; -import 'package:eva_mobile/features/diary/presentation/providers/diary_provider.dart'; -import 'package:eva_mobile/features/diary/presentation/widgets/summary_skeleton_loader.dart'; -import 'package:eva_mobile/theme/app_colors.dart'; -import 'package:eva_mobile/widgets/app_back_button.dart'; -import 'package:eva_mobile/widgets/custom_button.dart'; -import 'package:eva_mobile/widgets/glass_card.dart'; -import 'package:eva_mobile/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; -} +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../../../widgets/staggered_entrance.dart'; +import '../../../chat/presentation/providers/chat_provider.dart'; +import '../../data/models/diary_entry_model.dart'; +import '../../data/datasources/diary_fallback_fixture.dart'; +import '../providers/diary_provider.dart'; +import '../widgets/diary_emotion_distribution_card.dart'; +import '../widgets/diary_insight_card.dart'; +import '../widgets/diary_key_events_card.dart'; +import '../widgets/diary_reflection_card.dart'; +import '../widgets/diary_transcript_section.dart'; + +/// Screen displaying comprehensive AI-synthesized diary details, cumulative daily insights, +/// session breakdowns, emotional reflections, and voice transcripts. class AiDiaryDetailScreen extends ConsumerStatefulWidget { final Map? journalData; - const AiDiaryDetailScreen({super.key, this.journalData}); + const AiDiaryDetailScreen({ + super.key, + this.journalData, + }); @override ConsumerState createState() => _AiDiaryDetailScreenState(); @@ -37,7 +33,6 @@ class _AiDiaryDetailScreenState extends ConsumerState { late String _selectedSessionId; bool _isInitialized = false; Map? _diaryData; - bool _isTranscriptExpanded = false; // Streaming and Skeleton State bool _isStreaming = false; @@ -53,11 +48,7 @@ class _AiDiaryDetailScreenState extends ConsumerState { (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'; - } + _selectedSessionId = args['selectedSessionId']?.toString() ?? 'all'; final bool isSingle = _diaryData!['isSingleSession'] == true; final dId = _diaryData!['id']; @@ -118,13 +109,6 @@ class _AiDiaryDetailScreenState extends ConsumerState { _streamError = true; }); } - } finally { - if (mounted && _isStreaming) { - setState(() { - _isStreaming = false; - _isLoadingSkeleton = false; - }); - } } } @@ -161,247 +145,30 @@ class _AiDiaryDetailScreenState extends ConsumerState { }); } } catch (_) { - // Keep existing data + // Graceful fallback to existing passed state or default fixture } } @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 data = _diaryData ?? args ?? kDefaultDiaryDetailFallback; 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 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() - : []; + 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) { @@ -409,7 +176,7 @@ class _AiDiaryDetailScreenState extends ConsumerState { } } - // Compute active 7-emotion breakdown list based on selected session + // Compute active emotions breakdown based on selected session List> activeEmotions = []; if (_selectedSessionId == 'all') { if (data['emotionsBreakdown'] is List && (data['emotionsBreakdown'] as List).isNotEmpty) { @@ -459,1189 +226,284 @@ class _AiDiaryDetailScreenState extends ConsumerState { 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', - }); - } - } + activeTranscripts.addAll( + rawTrans.map((t) => Map.from(t as Map)), + ); } } } 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', - }); - } + final rawTrans = s['transcripts']; + if (rawTrans is List) { + activeTranscripts.addAll( + rawTrans.map((t) => Map.from(t as Map)), + ); } + break; } } } - 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: [ - - const AppBackButton(), - const SizedBox(width: 12), - - 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, - ), + backgroundColor: const Color(0xFFF9F9FB), + body: SafeArea( + child: Column( + children: [ + // Top App Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data['title']?.toString() ?? 'Detail Jurnal Refleksi', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, ), - 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, - ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + isSingleSession + ? '${data['date'] ?? 'Hari ini'} • ${data['moodTag'] ?? 'Tenang & Nyaman'}' + : '${data['date'] ?? 'Hari ini'} • ${sessions.length} Sesi Suara', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textSecondary, ), - ], - ), + ), + ], ), - ], - ), + ), + ], ), ), const Divider(height: 1, color: Color(0xFFEBECEF)), - - - // Main Body Content - - Expanded( - - child: SingleChildScrollView( - - padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), - - child: Column( - - crossAxisAlignment: CrossAxisAlignment.start, - - children: [ - - // MENTAL HEALTH ALERT CARD (If Risk Detected) - if (hasRisk) ...[ - StaggeredEntrance( - index: 1, - child: Container( - + // 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), - + color: const Color(0xFFE57373), borderRadius: BorderRadius.circular(12), - ), - - child: const Icon( - - Icons.warning_amber_rounded, - - color: Color(0xFFD32F2F), - - size: 22, - - ), - + child: const Icon(Icons.crisis_alert, color: Colors.white, size: 20), ), - 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), - ), - ), - ], + child: Text( + data['riskWarning']['title']?.toString() ?? + 'Indikasi Distress Terdeteksi', + style: GoogleFonts.inter( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: const Color(0xFFC62828), + ), ), ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 10), Text( - data['riskWarning']?['message']?.toString() ?? 'Perhatikan kondisi emosionalmu hari ini.', + data['riskWarning']['message']?.toString() ?? + 'Pola emosi kumulatif menunjukkan beban emosional yang tinggi.', style: GoogleFonts.inter( fontSize: 13, color: AppColors.textPrimary, - height: 1.4, + height: 1.45, ), ), + ], + ), + ), + ), + const SizedBox(height: 20), + ], - const SizedBox(height: 16), - - Row( + // 1. WAWASAN AI KUMULATIF / RINGKASAN SESI + DiaryInsightCard( + isSingleSession: isSingleSession, + sessionCount: sessions.length, + lastSessionTime: data['lastSessionTime']?.toString(), + isLoadingSkeleton: _isLoadingSkeleton, + isStreaming: _isStreaming, + streamError: _streamError, + streamedSummary: _streamedSummary, + summaryText: (isSingleSession + ? (_streamedSummary ?? data['summary'] ?? data['aiInsight']) + : data['aiInsight']) + ?.toString(), + onRetry: () { + final convId = data['conversationId'] ?? data['id']; + if (convId != null) { + _streamSummary(convId.toString()); + } + }, + ), + const SizedBox(height: 16), - children: [ + if (!isSingleSession) ...[ + StaggeredEntrance( + index: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 2. PERISTIWA PENTING KUMULATIF + DiaryKeyEventsCard(events: importantEvents), + const SizedBox(height: 16), - 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, - ), - ), - ], - ), - ), - ], + // 3. REFLEKSI EMOSIONAL + DiaryReflectionCard( + reflectionText: data['emotionalReflection']?.toString(), ), - 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: 24), ], ), ), - ), - 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, - - ), - - ), - - ), - - ], - - ), + ], - ), + 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: 16), - - - - // 3. REFLEKSI EMOSIONAL - - GlassCard( - - width: double.infinity, - - padding: const EdgeInsets.all(20), - - child: Column( - - crossAxisAlignment: CrossAxisAlignment.start, - - children: [ - - Row( - + ), + const SizedBox(height: 10), + SizedBox( + height: 46, + child: ListView( + clipBehavior: Clip.none, + padding: const EdgeInsets.symmetric(vertical: 3), + scrollDirection: Axis.horizontal, 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: 46, - child: ListView( - clipBehavior: Clip.none, - padding: const EdgeInsets.symmetric(vertical: 3), - scrollDirection: Axis.horizontal, - children: [ - ChoiceChip( - showCheckmark: false, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - label: Text('Semua Sesi (${sessions.length})'), - selected: _selectedSessionId == 'all', - selectedColor: AppColors.primaryContainer, - backgroundColor: Colors.white.withValues(alpha: 0.9), - labelStyle: GoogleFonts.inter( - fontSize: 12, - fontWeight: _selectedSessionId == 'all' ? FontWeight.w700 : FontWeight.w500, - color: _selectedSessionId == 'all' ? AppColors.primary : AppColors.textSecondary, - ), - 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( + ChoiceChip( showCheckmark: false, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - label: Text('$emoji $timeStr'), - selected: isSel, + 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: isSel ? FontWeight.w700 : FontWeight.w500, - color: isSel ? AppColors.primary : AppColors.textSecondary, + fontWeight: _selectedSessionId == 'all' + ? FontWeight.w700 + : FontWeight.w500, + color: _selectedSessionId == 'all' + ? 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; - }), + onSelected: (_) => setState(() => _selectedSessionId = 'all'), ), - ); - }), - ], - ), - ), - 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(width: 8), + ...sessions.map((sess) { + final sId = sess['id']?.toString() ?? ''; + final isSel = _selectedSessionId == sId; + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ChoiceChip( + showCheckmark: false, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + label: Text(sess['title']?.toString() ?? 'Sesi'), + selected: isSel, + selectedColor: AppColors.primaryContainer, + backgroundColor: Colors.white.withValues(alpha: 0.9), + labelStyle: GoogleFonts.inter( + fontSize: 12, + fontWeight: isSel ? FontWeight.w700 : FontWeight.w500, + color: isSel ? AppColors.primary : AppColors.textSecondary, ), + onSelected: (_) => setState(() => _selectedSessionId = sId), ), - ], - ), - const SizedBox(height: 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), + const SizedBox(height: 20), + ], - 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, - ), - ), - ], - ), - ), + // 4. ANALISIS EMOSI PER SESI (7 Parameter Emosi Filtered) + DiaryEmotionDistributionCard( + activeEmotions: activeEmotions, + isSingleSession: isSingleSession, + selectedSessionId: _selectedSessionId, ), ], - ], - ], - ), ), - const SizedBox(height: 24), - - ], - - ), + ), + const SizedBox(height: 20), + // 5. TRANSKRIP PERCAKAPAN SUARA + DiaryTranscriptSection( + transcripts: activeTranscripts, + isSingleSession: isSingleSession, + ), + const SizedBox(height: 24), + ], ), - ), - - ], - - ), - + ), + ], ), - ), - ); - } - } diff --git a/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_emotion_distribution_card.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_emotion_distribution_card.dart new file mode 100644 index 0000000..25ef94e --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_emotion_distribution_card.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +/// Helper to parse color objects or hex string fallback. +Color parseEmotionColor(dynamic colorVal, Color fallback) { + if (colorVal is Color) return colorVal; + if (colorVal is String && colorVal.startsWith('#')) { + final hex = colorVal.replaceAll('#', ''); + if (hex.length == 6) { + return Color(int.parse('FF$hex', radix: 16)); + } + } + return fallback; +} + +/// Card rendering the 7-emotion distribution parameters with animated percentage progress bars. +class DiaryEmotionDistributionCard extends StatelessWidget { + final List> activeEmotions; + final bool isSingleSession; + final String selectedSessionId; + + const DiaryEmotionDistributionCard({ + super.key, + required this.activeEmotions, + required this.isSingleSession, + required this.selectedSessionId, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'ANALISIS 7 PARAMETER EMOSI', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFEADBFF), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + isSingleSession + ? 'SESI INI' + : (selectedSessionId == 'all' ? 'KUMULATIF' : 'SESI SPESIFIK'), + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + children: activeEmotions.map((emo) { + final double pct = + ((emo['percent'] as num?)?.toDouble() ?? 0.0).clamp(0.0, 1.0); + final int pctInt = (pct * 100).round(); + final Color emoColor = parseEmotionColor(emo['color'], AppColors.primary); + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + emo['emoji']?.toString() ?? '✨', + style: const TextStyle(fontSize: 16), + ), + const SizedBox(width: 8), + Text( + emo['label']?.toString() ?? 'Emosi', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + Text( + '$pctInt%', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: pct, + minHeight: 7, + backgroundColor: const Color(0xFFE2E4F0), + valueColor: AlwaysStoppedAnimation(emoColor), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_insight_card.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_insight_card.dart new file mode 100644 index 0000000..2059d44 --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_insight_card.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; +import '../../../../widgets/staggered_entrance.dart'; +import 'summary_skeleton_loader.dart'; + +/// Card presenting AI-synthesized cumulative insights or standalone session summary. +class DiaryInsightCard extends StatelessWidget { + final bool isSingleSession; + final int sessionCount; + final String? lastSessionTime; + final bool isLoadingSkeleton; + final bool isStreaming; + final bool streamError; + final String? streamedSummary; + final String? summaryText; + final VoidCallback? onRetry; + + const DiaryInsightCard({ + super.key, + required this.isSingleSession, + required this.sessionCount, + this.lastSessionTime, + this.isLoadingSkeleton = false, + this.isStreaming = false, + this.streamError = false, + this.streamedSummary, + this.summaryText, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return StaggeredEntrance( + index: 2, + child: GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.auto_awesome, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isSingleSession ? 'RINGKASAN PERCAKAPAN' : 'WAWASAN AI KUMULATIF', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + letterSpacing: 0.8, + ), + ), + Text( + isSingleSession + ? '✨ Ringkasan cerdas percakapan sesi suara bersama EVA' + : '✨ Diperbarui setelah Sesi #$sessionCount (${lastSessionTime ?? 'Hari ini'})', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + if (isSingleSession && isLoadingSkeleton) + const SummarySkeletonLoader() + else if (isSingleSession && isStreaming) + RichText( + text: TextSpan( + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textPrimary, + height: 1.5, + ), + children: [ + TextSpan(text: streamedSummary ?? ''), + const TextSpan( + text: ' ▋', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ) + else if (isSingleSession && + streamError && + (streamedSummary == null || streamedSummary!.isEmpty)) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ringkasan percakapan belum berhasil dimuat.', + style: GoogleFonts.inter( + fontSize: 13, + color: AppColors.textLight, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 10), + if (onRetry != null) + InkWell( + onTap: onRetry, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.auto_awesome, size: 14, color: AppColors.primary), + const SizedBox(width: 6), + Text( + '✨ Generate Ringkasan', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ], + ) + else + Text( + summaryText ?? + 'Analisis AI menunjukkan kondisi emosional kamu hari ini cukup stabil.', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textPrimary, + height: 1.5, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_key_events_card.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_key_events_card.dart new file mode 100644 index 0000000..b24b25c --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_key_events_card.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +/// Card displaying cumulative key events and timestamps for the diary entry. +class DiaryKeyEventsCard extends StatelessWidget { + final List events; + + const DiaryKeyEventsCard({ + super.key, + required this.events, + }); + + @override + Widget build(BuildContext context) { + return GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFF489BB8), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.calendar_today_outlined, + color: Colors.white, + size: 18, + ), + ), + const SizedBox(width: 10), + Text( + 'PERISTIWA PENTING HARIAN', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF20667B), + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 14), + ...events.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 6), + width: 6, + height: 6, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + item, + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_reflection_card.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_reflection_card.dart new file mode 100644 index 0000000..5e7f48e --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_reflection_card.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +/// Card displaying cognitive and emotional reflection for the diary entry. +class DiaryReflectionCard extends StatelessWidget { + final String? reflectionText; + + const DiaryReflectionCard({ + super.key, + this.reflectionText, + }); + + @override + Widget build(BuildContext context) { + return GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFF605A79), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.psychology_outlined, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 10), + Text( + 'REFLEKSI EMOSIONAL HARIAN', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF605A79), + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + reflectionText ?? + 'Merasa lebih tenang dan terarah setelah berefleksi bersama EVA.', + style: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textPrimary, + height: 1.5, + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_transcript_section.dart b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_transcript_section.dart new file mode 100644 index 0000000..7e0f32a --- /dev/null +++ b/apps/eva_mobile/lib/features/diary/presentation/widgets/diary_transcript_section.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/staggered_entrance.dart'; + +/// Section rendering voice call transcripts with user/EVA speech bubbles and expand/collapse support. +class DiaryTranscriptSection extends StatefulWidget { + final List> transcripts; + final bool isSingleSession; + final int initialLimit; + + const DiaryTranscriptSection({ + super.key, + required this.transcripts, + this.isSingleSession = false, + this.initialLimit = 4, + }); + + @override + State createState() => _DiaryTranscriptSectionState(); +} + +class _DiaryTranscriptSectionState extends State { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + final transcripts = widget.transcripts; + final bool canExpand = transcripts.length > widget.initialLimit; + final int displayCount = (_isExpanded || !canExpand) + ? transcripts.length + : widget.initialLimit; + + return StaggeredEntrance( + index: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'TRANSKRIP SUARA', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + canExpand && !_isExpanded + ? '$displayCount dari ${transcripts.length}' + : '${transcripts.length} Dialog', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + if (transcripts.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + children: [ + const Icon(Icons.forum_outlined, size: 36, color: Color(0xFFB0B7C3)), + const SizedBox(height: 8), + Text( + 'Tidak ada transkrip rekaman suara tersimpan untuk sesi ini', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ) + else ...[ + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: displayCount, + itemBuilder: (context, index) { + final item = transcripts[index]; + final bool isUser = item['isUser'] as bool; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isUser + ? const Color(0xFFF0F2FF) + : Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isUser + ? AppColors.primary.withValues(alpha: 0.2) + : Colors.grey.shade200, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isUser + ? AppColors.primary + : const Color(0xFFEADBFF), + shape: BoxShape.circle, + ), + child: Icon( + isUser ? Icons.person : Icons.auto_awesome, + size: 13, + color: isUser ? Colors.white : AppColors.primary, + ), + ), + const SizedBox(width: 8), + Text( + isUser ? 'Kamu' : 'EVA AI', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + if (item['emotionTag'] != null && + item['emotionTag'].toString().isNotEmpty) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFEADBFF), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + item['emotionTag'].toString(), + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 10), + Text( + item['text']?.toString() ?? '', + style: GoogleFonts.inter( + fontSize: 13.5, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + if (!widget.isSingleSession && + item['time'] != null && + item['time'].toString().isNotEmpty) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.bottomRight, + child: Text( + item['time']?.toString() ?? '', + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textLight, + ), + ), + ), + ], + ], + ), + ); + }, + ), + if (canExpand) ...[ + const SizedBox(height: 4), + InkWell( + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + borderRadius: BorderRadius.circular(14), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.25), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.06), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _isExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + color: AppColors.primary, + size: 20, + ), + const SizedBox(width: 6), + Text( + _isExpanded + ? 'Tampilkan Lebih Sedikit' + : 'Lihat Selengkapnya (${transcripts.length - widget.initialLimit} pesan lainnya)', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ], + ], + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart b/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart index 3f93aed..5a85ebc 100644 --- a/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:eva_mobile/config/app_config.dart'; -import 'package:eva_mobile/core/utils/responsive_layout_helper.dart'; import 'package:eva_mobile/features/auth/domain/entities/user_entity.dart'; import 'package:eva_mobile/features/auth/presentation/providers/auth_provider.dart'; import 'package:eva_mobile/features/chat/domain/entities/conversation_entity.dart'; @@ -14,13 +13,16 @@ import 'package:eva_mobile/features/dass/presentation/providers/dass_provider.da import 'package:eva_mobile/features/diary/domain/entities/diary_entry_entity.dart'; import 'package:eva_mobile/features/diary/presentation/providers/diary_provider.dart'; import 'package:eva_mobile/features/home/presentation/widgets/home_activity_modal.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/home_daily_progress_card.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/home_eva_insight_card.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/home_quick_actions.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/home_recommendations_section.dart'; import 'package:eva_mobile/features/home/presentation/widgets/profile_dropdown_modal.dart'; import 'package:eva_mobile/features/recommendations/presentation/providers/recommendation_provider.dart'; import 'package:eva_mobile/providers/daily_progress_provider.dart'; import 'package:eva_mobile/services/daily_progress_local_service.dart'; import 'package:eva_mobile/theme/app_colors.dart'; import 'package:eva_mobile/widgets/app_snack_bar.dart'; -import 'package:eva_mobile/widgets/glass_card.dart'; import 'package:eva_mobile/widgets/staggered_entrance.dart'; class HomeScreen extends ConsumerStatefulWidget { @@ -70,30 +72,6 @@ class HomeScreenState extends ConsumerState with WidgetsBindingObser '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', @@ -540,1049 +518,126 @@ class HomeScreenState extends ConsumerState with WidgetsBindingObser ], ), ), - const SizedBox(height: 18), - - // Empathetic AI Psychologist Insight Card - StaggeredEntrance( - index: 2, - child: _buildLunaInsightCard(), - ), - const SizedBox(height: 20), + const SizedBox(height: 18), - // 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(), - ), - ], - ), + // Empathetic AI Psychologist Insight Card + StaggeredEntrance( + index: 2, + child: HomeEvaInsightCard(insightText: _aiInsight), ), - ), - ), - ), - ); - } + const SizedBox(height: 20), - // -------------------------------------------------------------------------- - // 1. Empathetic Psychological Insight Card from Eva - // -------------------------------------------------------------------------- - Widget _buildLunaInsightCard() { - 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, - ), - ), + // Quick Navigation Shortcuts + StaggeredEntrance( + index: 3, + child: HomeQuickActions( + onTapChat: () async { + await Navigator.pushNamed(context, '/chat'); + refresh(); + }, + onTapDiary: () async { + if (widget.onNavigateTab != null) { + widget.onNavigateTab!(1); + } else { + await Navigator.pushNamed(context, '/diary'); + refresh(); + } + }, + onTapDass: () async { + await Navigator.pushNamed(context, '/dass_assessment'); + refresh(); + }, + dassSubtitle: _dassVerifiedByUser + ? 'Stres: $_dassStressScore • Cemas: $_dassAnxietyScore • Depresi: $_dassDepressionScore' + : (_hasTodayDass && _dassStatus == 'auto_extracted' + ? 'Tersintesis AI • Ketuk untuk meninjau' + : 'Evaluasi & koreksi 21 butir emosi'), + showDassNotificationDot: _hasTodayDass && + _dassStatus == 'auto_extracted' && + !_dassVerifiedByUser, ), - 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, - ), - ], - ), - ); - } + const SizedBox(height: 24), - // -------------------------------------------------------------------------- - // 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, + // Evidence-Based Daily Progress Section (3 Pillars) + StaggeredEntrance( + index: 4, + child: HomeDailyProgressCard( + completedCount: _completedRoutinesCount, + isConversationDone: + _localProgress.hasConversation || _todayConversationsCount > 0, + isDiaryDone: _localProgress.hasDiary || _hasTodayDiary, + isExerciseDone: _localProgress.completedActivityIds.isNotEmpty || + (_todayActivity != null && + _todayActivity!['isCompleted'] == true) || + _recommendations.any((r) => r['isCompleted'] == true), + todayConversationsCount: _todayConversationsCount, + onTapConversation: () async { + await Navigator.pushNamed(context, '/chat'); + refresh(); + }, + onTapDiary: () async { + if (widget.onNavigateTab != null) { + widget.onNavigateTab!(1); + } else { + await Navigator.pushNamed(context, '/diary'); + refresh(); + } + }, + onTapExercise: () async { + await Navigator.pushNamed(context, '/recommendation'); + refresh(); + }, ), ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), + const SizedBox(height: 24), - // 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), + // Latest Clinically-Informed Recommendation + StaggeredEntrance( + index: 5, + child: _buildLatestRecommendationSection(), ), ], ), - 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) + final Map activeRec = (_todayActivity != null && + _todayActivity!["isCompleted"] != true) ? _todayActivity! : (_recommendations.isNotEmpty ? _recommendations.firstWhere( - (r) => r['isCompleted'] != true, + (r) => r["isCompleted"] != true, orElse: () => _todayActivity ?? _recommendations.first, ) : _defaultRecommendations.first); - final id = activeRec['id']?.toString() ?? '1'; - final isDone = activeRec['isCompleted'] == true || + 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) { - AppSnackBar.showPrimary( - context, - 'Kamu sudah menyelesaikan latihan ini hari ini! Kerja bagus, istirahatlah sejenak dan lanjutkan kembali besok 🌿', - icon: Icons.info_outline, - ); - 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, - ), - ), - ], - ), - ), - ), - ], - ), - ], - ), - ), - ), - ], + return HomeRecommendationsSection( + activeRec: activeRec, + conditionLabel: _conditionLabel, + severityLevel: _severityLevel, + isCrisis: _isCrisis, + crisisGuidance: _crisisGuidance, + crisisHotline: _crisisHotline, + isDone: isDone, + onSeeAll: () async { + await Navigator.pushNamed(context, "/recommendation"); + _loadHomeScreenData(); + }, + onSupportTap: () { + Navigator.pushNamed(context, "/support"); + }, + onOpenDetail: () => _showActivityDetailModal(context, activeRec), + onToggleComplete: (toggleId) => _toggleRecommendationComplete(toggleId), ); } diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_crisis_banner.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_crisis_banner.dart new file mode 100644 index 0000000..096b93b --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_crisis_banner.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// High-visibility crisis support banner displayed when severe distress indicators are detected. +class HomeCrisisBanner extends StatelessWidget { + final String crisisGuidance; + final String crisisHotline; + final VoidCallback onSupportTap; + + const HomeCrisisBanner({ + super.key, + required this.crisisGuidance, + required this.crisisHotline, + required this.onSupportTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFECACA), width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.red.withValues(alpha: 0.08), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: const BoxDecoration( + color: Color(0xFFFEE2E2), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.favorite, + color: Color(0xFFDC2626), + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Kamu Berharga & Tidak Sendirian', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w800, + color: const Color(0xFF991B1B), + ), + ), + Text( + 'Prioritas Utama: Keselamatan & Bantuan Nyata', + style: GoogleFonts.inter( + fontSize: 11, + color: const Color(0xFFB91C1C), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + crisisGuidance, + style: GoogleFonts.inter( + fontSize: 11.5, + height: 1.4, + color: const Color(0xFF7F1D1D), + ), + ), + const SizedBox(height: 6), + Text( + 'Hotline: $crisisHotline', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF991B1B), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: onSupportTap, + icon: const Icon(Icons.support_agent, size: 16, color: Colors.white), + label: Text( + 'Pusat Bantuan & Hotline Darurat', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFDC2626), + padding: const EdgeInsets.symmetric(vertical: 9), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_daily_progress_card.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_daily_progress_card.dart new file mode 100644 index 0000000..11170ef --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_daily_progress_card.dart @@ -0,0 +1,361 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../core/utils/responsive_layout_helper.dart'; +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +/// Card displaying the 3 evidence-based daily self-care habit routines with segmented progress bars. +class HomeDailyProgressCard extends StatelessWidget { + final int completedCount; + final bool isConversationDone; + final bool isDiaryDone; + final bool isExerciseDone; + final int todayConversationsCount; + final VoidCallback onTapConversation; + final VoidCallback onTapDiary; + final VoidCallback onTapExercise; + + const HomeDailyProgressCard({ + super.key, + required this.completedCount, + required this.isConversationDone, + required this.isDiaryDone, + required this.isExerciseDone, + required this.todayConversationsCount, + required this.onTapConversation, + required this.onTapDiary, + required this.onTapExercise, + }); + + @override + Widget build(BuildContext context) { + final progressFraction = (completedCount / 3.0).clamp(0.0, 1.0); + + String progressMotivation; + if (completedCount == 3) { + progressMotivation = + 'Luar biasa! Semua target perawatan dirimu tercapai hari ini 🌟'; + } else if (completedCount == 2) { + progressMotivation = + 'Tinggal 1 langkah lagi untuk melengkapi rutinitas terapeutikmu.'; + } else if (completedCount == 1) { + progressMotivation = + 'Kemajuan yang sangat baik! Terus jaga ritme emosionalmu.'; + } else { + progressMotivation = + 'Mulai harimu dengan satu langkah kecil penuh perhatian bersama EVA.'; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: completedCount == 3 + ? const Color(0xFF10B981).withValues(alpha: 0.15) + : AppColors.primary.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + completedCount == 3 ? Icons.stars_rounded : Icons.spa_rounded, + size: 16, + color: completedCount == 3 + ? const Color(0xFF10B981) + : AppColors.primary, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Perawatan Diri', + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: context.responsiveFont(15), + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: completedCount == 3 + ? const Color(0xFFD1FAE5) + : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: completedCount == 3 + ? const Color(0xFF10B981).withValues(alpha: 0.3) + : AppColors.primary.withValues(alpha: 0.15), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (completedCount == 3) ...[ + const Icon( + Icons.check_circle_rounded, + size: 13, + color: Color(0xFF047857), + ), + const SizedBox(width: 4), + ], + Text( + '$completedCount/3 Selesai', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: completedCount == 3 + ? const Color(0xFF047857) + : AppColors.primary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + // Premium Progress Container + GlassCard( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Segmented 3-Step Progress Bars + Row( + children: [ + Expanded( + child: _buildSegmentBar(isActive: isConversationDone), + ), + const SizedBox(width: 6), + Expanded( + child: _buildSegmentBar(isActive: isDiaryDone), + ), + const SizedBox(width: 6), + Expanded( + child: _buildSegmentBar(isActive: isExerciseDone), + ), + ], + ), + const SizedBox(height: 12), + + // Motivation & Percentage Banner + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: completedCount == 3 + ? const Color(0xFFF0FDF4) + : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: completedCount == 3 + ? const Color(0xFF86EFAC).withValues(alpha: 0.5) + : const Color(0xFFE2E8F0), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + completedCount == 3 + ? Icons.emoji_events_rounded + : Icons.auto_awesome, + size: 16, + color: completedCount == 3 + ? const Color(0xFF10B981) + : AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + progressMotivation, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: completedCount == 3 + ? const Color(0xFF065F46) + : AppColors.textSecondary, + height: 1.35, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: completedCount == 3 + ? const Color(0xFF10B981) + : AppColors.primary, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${(progressFraction * 100).round()}%', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + + // Routine 1: Sesi Curhat / Dialog Emosi + _buildProgressCheckItem( + title: 'Sesi Curhat Bersama Eva', + subtitle: isConversationDone + ? '$todayConversationsCount sesi dialog telah tercatat hari ini' + : 'Ekspresikan perasaanmu lewat suara atau teks', + icon: Icons.chat_bubble_outline_rounded, + isCompleted: isConversationDone, + onTap: onTapConversation, + ), + const SizedBox(height: 8), + + // Routine 2: Refleksi Jurnal AI + _buildProgressCheckItem( + title: 'Refleksi Jurnal Harian', + subtitle: isDiaryDone + ? 'Jurnal & analisis emosi telah disintesis' + : 'Baca rangkuman & pola emosimu hari ini', + icon: Icons.menu_book_rounded, + isCompleted: isDiaryDone, + onTap: onTapDiary, + ), + const SizedBox(height: 8), + + // Routine 3: Latihan Koping Terpandu + _buildProgressCheckItem( + title: 'Latihan Relaksasi & Koping', + subtitle: isExerciseDone + ? '1 latihan koping mandiri telah diselesaikan' + : 'Coba 1 teknik pernapasan atau mindfulness', + icon: Icons.spa_rounded, + isCompleted: isExerciseDone, + onTap: onTapExercise, + ), + ], + ), + ), + ], + ); + } + + Widget _buildSegmentBar({required bool isActive}) { + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 6, + decoration: BoxDecoration( + color: isActive ? AppColors.primary : const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ); + } + + Widget _buildProgressCheckItem({ + required String title, + required String subtitle, + required IconData icon, + required bool isCompleted, + required VoidCallback onTap, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isCompleted + ? AppColors.primaryContainer.withValues(alpha: 0.35) + : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isCompleted + ? AppColors.primary.withValues(alpha: 0.35) + : const Color(0xFFE2E8F0), + width: 1, + ), + ), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 250), + width: 22, + height: 22, + decoration: BoxDecoration( + color: isCompleted ? AppColors.primary : Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: isCompleted ? AppColors.primary : const Color(0xFFCBD5E1), + width: 1.5, + ), + ), + child: isCompleted + ? const Icon(Icons.check, size: 14, color: Colors.white) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: isCompleted + ? AppColors.primary + : AppColors.textPrimary, + decoration: + isCompleted ? TextDecoration.lineThrough : null, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11, + color: AppColors.textLight, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + icon, + size: 18, + color: isCompleted ? AppColors.primary : const Color(0xFF94A3B8), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_eva_insight_card.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_eva_insight_card.dart new file mode 100644 index 0000000..9be0f96 --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_eva_insight_card.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; + +/// Card displaying empathetic psychological insight from Eva AI based on daily reflections. +class HomeEvaInsightCard extends StatelessWidget { + final String insightText; + + const HomeEvaInsightCard({ + super.key, + required this.insightText, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: const LinearGradient( + colors: [ + Color(0xFFEDE9FE), + Color(0xFFF3E8FF), + Color(0xFFE0E7FF), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.08), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + border: Border.all( + color: Colors.white.withValues(alpha: 0.9), + width: 1.5, + ), + ), + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.auto_awesome, + size: 15, + color: AppColors.primary, + ), + ), + const SizedBox(width: 8), + Text( + 'CATATAN DARI EVA', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + '"$insightText"', + style: GoogleFonts.inter( + fontSize: 13, + fontStyle: FontStyle.italic, + height: 1.5, + fontWeight: FontWeight.w500, + color: const Color(0xFF333878), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_quick_actions.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_quick_actions.dart new file mode 100644 index 0000000..ecf393c --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_quick_actions.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/glass_card.dart'; + +/// Shortcuts grid for core mental health features (Chat, Diary, DASS-21 Assessment). +class HomeQuickActions extends StatelessWidget { + final VoidCallback onTapChat; + final VoidCallback onTapDiary; + final VoidCallback onTapDass; + final String dassSubtitle; + final bool showDassNotificationDot; + + const HomeQuickActions({ + super.key, + required this.onTapChat, + required this.onTapDiary, + required this.onTapDass, + required this.dassSubtitle, + this.showDassNotificationDot = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _buildQuickCard( + icon: Icons.chat_bubble_outline, + iconBg: const Color(0xFF5358CB), + iconColor: Colors.white, + title: 'Curhat ke EVA', + subtitle: 'Mulai dialog suara atau teks', + onTap: onTapChat, + ), + const SizedBox(height: 12), + _buildQuickCard( + icon: Icons.menu_book_outlined, + iconBg: const Color(0xFFE2DAFF), + iconColor: const Color(0xFF5358CB), + title: 'Jurnal Refleksi AI', + subtitle: 'Lihat rangkuman emosi harimu', + onTap: onTapDiary, + ), + const SizedBox(height: 12), + _buildQuickCard( + icon: Icons.psychology_outlined, + iconBg: const Color(0xFFE0F4FB), + iconColor: const Color(0xFF20667B), + title: 'Asesmen DASS-21', + subtitle: dassSubtitle, + showNotificationDot: showDassNotificationDot, + onTap: onTapDass, + ), + ], + ); + } + + Widget _buildQuickCard({ + required IconData icon, + required Color iconBg, + required Color iconColor, + required String title, + required String subtitle, + bool showNotificationDot = false, + required VoidCallback onTap, + }) { + return GlassCard( + width: double.infinity, + onTap: onTap, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: iconBg, + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: iconColor, size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + if (showNotificationDot) ...[ + const SizedBox(width: 8), + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFF59E0B), + shape: BoxShape.circle, + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12, + color: AppColors.textLight, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right, + size: 20, + color: AppColors.textLight, + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/home_recommendations_section.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/home_recommendations_section.dart new file mode 100644 index 0000000..55359c9 --- /dev/null +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/home_recommendations_section.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_snack_bar.dart'; +import 'home_crisis_banner.dart'; + +/// Section displaying personalized therapeutic CBT & mindfulness activities for the day. +class HomeRecommendationsSection extends StatelessWidget { + final Map activeRec; + final String conditionLabel; + final String severityLevel; + final bool isCrisis; + final String crisisGuidance; + final String crisisHotline; + final bool isDone; + final VoidCallback onSeeAll; + final VoidCallback onSupportTap; + final VoidCallback onOpenDetail; + final ValueChanged onToggleComplete; + + const HomeRecommendationsSection({ + super.key, + required this.activeRec, + required this.conditionLabel, + required this.severityLevel, + required this.isCrisis, + required this.crisisGuidance, + required this.crisisHotline, + required this.isDone, + required this.onSeeAll, + required this.onSupportTap, + required this.onOpenDetail, + required this.onToggleComplete, + }); + + static Color getSeverityColor(String severity) { + switch (severity.toLowerCase()) { + case 'sangat berat': + case 'extremely severe': + return const Color(0xFFDC2626); + case 'berat': + case 'severe': + return const Color(0xFFEA580C); + case 'sedang': + case 'moderate': + return const Color(0xFFD97706); + case 'ringan': + case 'mild': + return const Color(0xFF2563EB); + default: + return const Color(0xFF10B981); + } + } + + static String normalizeCategory(String cat) { + if (cat.toUpperCase() == 'CBT') return 'Teknik Kognitif (CBT)'; + if (cat.toUpperCase() == 'MINDFULNESS') return 'Latihan Mindfulness'; + if (cat.toUpperCase() == 'BREATHING') return 'Pernapasan Terarah'; + if (cat.toUpperCase() == 'REFLEKSI') return 'Jurnal Refleksi Diri'; + return cat; + } + + @override + Widget build(BuildContext context) { + final id = activeRec['id']?.toString() ?? '1'; + final rawCategory = activeRec['category']?.toString() ?? 'Self-Care'; + final category = normalizeCategory(rawCategory); + final duration = activeRec['duration']?.toString() ?? '5 menit'; + final title = activeRec['title']?.toString() ?? 'Latihan Relaksasi'; + final description = activeRec['description']?.toString() ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + 'Aktivitas Pilihan Hari Ini', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + GestureDetector( + onTap: onSeeAll, + child: Row( + children: [ + Text( + 'Lihat Semua', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + const SizedBox(width: 2), + const Icon( + Icons.arrow_forward_ios, + size: 10, + color: AppColors.primary, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + conditionLabel.isNotEmpty + ? conditionLabel + : 'Disesuaikan dengan kondisi emosionalmu hari ini', + style: GoogleFonts.inter( + fontSize: 12.5, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + + // Crisis Alert Banner (if applicable) + if (isCrisis) + HomeCrisisBanner( + crisisGuidance: crisisGuidance, + crisisHotline: crisisHotline, + onSupportTap: onSupportTap, + ), + + // Sleek Recommendation Card + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: const LinearGradient( + colors: [ + Color(0xFF4F46E5), + Color(0xFF6366F1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF4F46E5).withValues(alpha: 0.22), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top Tag Row: Category • Duration + Severity Pill + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$category • $duration'.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 0.5, + ), + ), + ), + ), + if (severityLevel != 'Normal') + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: getSeverityColor(severityLevel).withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: getSeverityColor(severityLevel).withValues(alpha: 0.5), + ), + ), + child: Text( + 'Level: $severityLevel', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Title + Text( + title, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + + // Description + Text( + description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 12.5, + height: 1.45, + color: Colors.white.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: 16), + + // Bottom Action Buttons: Mulai Latihan (CTA) + Tandai Selesai + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: onOpenDetail, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 11), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.play_circle_filled, + size: 18, + color: Color(0xFF4F46E5), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + isDone ? 'Buka Panduan' : 'Mulai Latihan', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFF4F46E5), + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: () { + if (isDone) { + AppSnackBar.showPrimary( + context, + 'Kamu sudah menyelesaikan latihan ini hari ini! Kerja bagus, istirahatlah sejenak dan lanjutkan kembali besok 🌿', + icon: Icons.info_outline, + ); + return; + } + onToggleComplete(id); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: isDone + ? const Color(0xFF10B981) + : Colors.white.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDone + ? const Color(0xFF10B981) + : Colors.white.withValues(alpha: 0.35), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isDone ? Icons.check_circle : Icons.check_circle_outline, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 6), + Text( + isDone ? 'Selesai' : 'Tandai', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart index 70181a3..7142797 100644 --- a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart +++ b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart @@ -54,7 +54,7 @@ class _InteractiveSpotlightOverlayState extends State= 0 && _measuredTargetRect != rect) { setState(() { _measuredTargetRect = rect; }); @@ -138,6 +141,13 @@ class _InteractiveSpotlightOverlayState extends State Date: Sat, 7 Nov 2026 21:07:11 +0700 Subject: [PATCH 3/7] refactor(mobile): eliminate dead code, standardize app config, and inject chat repository - Decouple VoiceSessionDetailScreen from legacy VoiceCallRepository using Riverpod chatRepositoryProvider - Delete obsolete lib/data/repositories/voice_call_repository.dart and wipe lib/data - Delete unused services: audio_recorder_service, stt_service, vad_audio_service - Delete obsolete widgets: emergency_contact_bottom_sheet, mood_checkin_bottom_sheet, and luna_loading_orb - Standardize AppConfig imports across all features to package:eva_mobile/config/app_config.dart - Align residual branding references from Luna to Eva in tutorial entities, assets, and tests - All 98 tests passing, 0 analyzer issues --- apps/eva_mobile/README.md | 2 +- .../lib/core/config/app_config.dart | 2 - .../lib/core/network/api_client.dart | 2 +- .../repositories/voice_call_repository.dart | 227 ----- .../datasources/remote_auth_datasource.dart | 2 +- .../presentation/providers/auth_provider.dart | 2 +- .../datasources/remote_chat_datasource.dart | 2 +- .../presentation/providers/chat_provider.dart | 2 +- .../presentation/providers/dass_provider.dart | 2 +- .../providers/diary_provider.dart | 2 +- .../providers/monitoring_provider.dart | 2 +- .../providers/recommendation_provider.dart | 2 +- .../domain/entities/tutorial_step.dart | 6 +- .../widgets/branded_guidance_card.dart | 6 +- .../interactive_spotlight_overlay.dart | 2 +- .../providers/voice_call_provider.dart | 2 +- .../screens/voice_session_detail_screen.dart | 55 +- apps/eva_mobile/lib/main.dart | 3 - .../lib/services/audio_recorder_service.dart | 47 -- apps/eva_mobile/lib/services/stt_service.dart | 136 --- .../lib/services/vad_audio_service.dart | 215 ----- .../emergency_contact_bottom_sheet.dart | 549 ------------ .../lib/widgets/eva_loading_orb.dart | 2 - .../lib/widgets/luna_loading_orb.dart | 2 - .../widgets/mood_checkin_bottom_sheet.dart | 797 ------------------ apps/eva_mobile/pubspec.yaml | 1 - .../tutorial/branded_guidance_card_test.dart | 8 +- .../interactive_spotlight_overlay_test.dart | 2 +- .../tutorial/main_shell_tutorial_test.dart | 6 +- .../voice_call/ai_call_controller_test.dart | 4 +- .../screens/ai_diary_detail_screen_test.dart | 8 +- .../screens/dass_assessment_screen_test.dart | 2 +- .../test/widgets/luna_loading_orb_test.dart | 28 - 33 files changed, 68 insertions(+), 2062 deletions(-) delete mode 100644 apps/eva_mobile/lib/core/config/app_config.dart delete mode 100644 apps/eva_mobile/lib/data/repositories/voice_call_repository.dart delete mode 100644 apps/eva_mobile/lib/services/audio_recorder_service.dart delete mode 100644 apps/eva_mobile/lib/services/stt_service.dart delete mode 100644 apps/eva_mobile/lib/services/vad_audio_service.dart delete mode 100644 apps/eva_mobile/lib/widgets/emergency_contact_bottom_sheet.dart delete mode 100644 apps/eva_mobile/lib/widgets/luna_loading_orb.dart delete mode 100644 apps/eva_mobile/lib/widgets/mood_checkin_bottom_sheet.dart delete mode 100644 apps/eva_mobile/test/widgets/luna_loading_orb_test.dart 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/core/config/app_config.dart b/apps/eva_mobile/lib/core/config/app_config.dart deleted file mode 100644 index dff04c5..0000000 --- a/apps/eva_mobile/lib/core/config/app_config.dart +++ /dev/null @@ -1,2 +0,0 @@ -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 index d40c1f7..1440b65 100644 --- a/apps/eva_mobile/lib/core/network/api_client.dart +++ b/apps/eva_mobile/lib/core/network/api_client.dart @@ -1,6 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -import '../config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; /// Custom Exception for API Errors class ApiException implements Exception { diff --git a/apps/eva_mobile/lib/data/repositories/voice_call_repository.dart b/apps/eva_mobile/lib/data/repositories/voice_call_repository.dart deleted file mode 100644 index 0bf27d5..0000000 --- a/apps/eva_mobile/lib/data/repositories/voice_call_repository.dart +++ /dev/null @@ -1,227 +0,0 @@ -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/remote_auth_datasource.dart b/apps/eva_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart index f4709ad..be24701 100644 --- a/apps/eva_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart +++ b/apps/eva_mobile/lib/features/auth/data/datasources/remote_auth_datasource.dart @@ -2,7 +2,7 @@ import 'auth_datasource.dart'; import '../models/user_model.dart'; import '../models/emergency_contact_model.dart'; import '../../../../core/network/api_client.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../../../core/storage/token_storage.dart'; class RemoteAuthDataSource implements AuthDataSource { diff --git a/apps/eva_mobile/lib/features/auth/presentation/providers/auth_provider.dart b/apps/eva_mobile/lib/features/auth/presentation/providers/auth_provider.dart index 6fbbf26..306f7f9 100644 --- a/apps/eva_mobile/lib/features/auth/presentation/providers/auth_provider.dart +++ b/apps/eva_mobile/lib/features/auth/presentation/providers/auth_provider.dart @@ -1,5 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/auth_repository.dart'; import '../../domain/entities/user_entity.dart'; import '../../domain/entities/emergency_contact_entity.dart'; diff --git a/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart b/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart index a1aa2fc..e205c4c 100644 --- a/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart +++ b/apps/eva_mobile/lib/features/chat/data/datasources/remote_chat_datasource.dart @@ -4,7 +4,7 @@ import 'package:http/http.dart' as http; import 'chat_datasource.dart'; import '../models/conversation_model.dart'; import '../models/chat_message_model.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../../../core/network/api_client.dart'; class RemoteChatDataSource implements ChatDataSource { diff --git a/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart b/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart index 44bf6bf..585f230 100644 --- a/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart +++ b/apps/eva_mobile/lib/features/chat/presentation/providers/chat_provider.dart @@ -1,5 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/chat_repository.dart'; import '../../domain/entities/conversation_entity.dart'; import '../../data/datasources/mock_chat_datasource.dart'; diff --git a/apps/eva_mobile/lib/features/dass/presentation/providers/dass_provider.dart b/apps/eva_mobile/lib/features/dass/presentation/providers/dass_provider.dart index 346f7bb..119cd3e 100644 --- a/apps/eva_mobile/lib/features/dass/presentation/providers/dass_provider.dart +++ b/apps/eva_mobile/lib/features/dass/presentation/providers/dass_provider.dart @@ -1,5 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../data/datasources/dass_datasource.dart'; import '../../data/repositories/dass_repository_impl.dart'; import '../../data/repositories/fallback_dass_repository.dart'; diff --git a/apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart b/apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart index 6425438..de43116 100644 --- a/apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart +++ b/apps/eva_mobile/lib/features/diary/presentation/providers/diary_provider.dart @@ -1,5 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/diary_repository.dart'; import '../../domain/entities/diary_entry_entity.dart'; import '../../data/datasources/mock_diary_datasource.dart'; diff --git a/apps/eva_mobile/lib/features/monitoring/presentation/providers/monitoring_provider.dart b/apps/eva_mobile/lib/features/monitoring/presentation/providers/monitoring_provider.dart index aa69eaf..f57a7c1 100644 --- a/apps/eva_mobile/lib/features/monitoring/presentation/providers/monitoring_provider.dart +++ b/apps/eva_mobile/lib/features/monitoring/presentation/providers/monitoring_provider.dart @@ -1,5 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/monitoring_repository.dart'; import '../../domain/entities/monitoring_data_entity.dart'; import '../../data/datasources/mock_monitoring_datasource.dart'; diff --git a/apps/eva_mobile/lib/features/recommendations/presentation/providers/recommendation_provider.dart b/apps/eva_mobile/lib/features/recommendations/presentation/providers/recommendation_provider.dart index 9624c98..55d173e 100644 --- a/apps/eva_mobile/lib/features/recommendations/presentation/providers/recommendation_provider.dart +++ b/apps/eva_mobile/lib/features/recommendations/presentation/providers/recommendation_provider.dart @@ -1,5 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/recommendation_repository.dart'; import '../../domain/entities/recommendation_entity.dart'; import '../../data/datasources/mock_recommendation_datasource.dart'; diff --git a/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart b/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart index 5e0dfb7..170defb 100644 --- a/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart +++ b/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart @@ -29,7 +29,7 @@ class TutorialContent { required this.title, required this.description, this.actionHint, - this.badgeText = 'LUNA AI', + this.badgeText = 'EVA AI', this.shape = SpotlightShape.circle, this.pointerDirection = PointerDirection.down, this.isInteractiveTarget = true, @@ -37,11 +37,11 @@ class TutorialContent { static const Map stepContents = { TutorialStep.voiceSession: TutorialContent( - title: 'Cerita ke Luna, yuk!', + title: 'Cerita ke Eva, yuk!', description: 'Tekan tombol tengah untuk mulai berbicara. Kamu bisa mulai saat sudah siap.', actionHint: 'Ketuk tombol di bawah untuk mencoba', - badgeText: 'REKOMENDASI LUNA', + badgeText: 'REKOMENDASI EVA', shape: SpotlightShape.circle, pointerDirection: PointerDirection.down, isInteractiveTarget: true, diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart index 320a4ff..f3d1bb8 100644 --- a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart +++ b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart @@ -3,7 +3,7 @@ import 'package:google_fonts/google_fonts.dart'; import '../../../../theme/app_colors.dart'; import '../../domain/entities/tutorial_step.dart'; -/// A branded, calming guidance card styled specifically for Luna/Eva AI. +/// A branded, calming guidance card styled specifically for Eva AI. /// Combines typography hierarchy, subtle brand identity accents, and an optional /// subtle downward/upward pointer connected seamlessly to the card. class BrandedGuidanceCard extends StatelessWidget { @@ -22,7 +22,7 @@ class BrandedGuidanceCard extends StatelessWidget { required this.title, required this.description, this.actionHint, - this.badgeText = 'LUNA AI', + this.badgeText = 'EVA AI', this.pointerDirection = PointerDirection.down, this.pointerHorizontalOffset = 0.0, this.isInteractiveTarget = true, @@ -85,7 +85,7 @@ class BrandedGuidanceCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Image.asset( - 'assets/images/luna_logo_square.png', + 'assets/images/eva_logo_square.png', width: 14, height: 14, errorBuilder: (context, error, stackTrace) => const Icon( diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart index 7142797..5eb4102 100644 --- a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart +++ b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart @@ -4,7 +4,7 @@ import '../../../../theme/app_colors.dart'; import '../../domain/entities/tutorial_step.dart'; import 'branded_guidance_card.dart'; -/// An interactive, branded spotlight overlay for Luna/Eva AI. +/// An interactive, branded spotlight overlay for Eva AI. /// Renders a dimmed, calming backdrop with an exact cutout around the target widget, /// soft ambient halo ring, and a dynamically positioned [BrandedGuidanceCard]. /// diff --git a/apps/eva_mobile/lib/features/voice_call/presentation/providers/voice_call_provider.dart b/apps/eva_mobile/lib/features/voice_call/presentation/providers/voice_call_provider.dart index 056d393..d8f785c 100644 --- a/apps/eva_mobile/lib/features/voice_call/presentation/providers/voice_call_provider.dart +++ b/apps/eva_mobile/lib/features/voice_call/presentation/providers/voice_call_provider.dart @@ -1,5 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../../core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import '../../domain/repositories/voice_call_repository.dart'; import '../../domain/entities/voice_session_entity.dart'; import '../../data/datasources/mock_voice_call_datasource.dart'; diff --git a/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_session_detail_screen.dart b/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_session_detail_screen.dart index 6f271c1..4c24904 100644 --- a/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_session_detail_screen.dart +++ b/apps/eva_mobile/lib/features/voice_call/presentation/screens/voice_session_detail_screen.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:eva_mobile/data/repositories/voice_call_repository.dart'; +import 'package:eva_mobile/features/chat/presentation/providers/chat_provider.dart'; import 'package:eva_mobile/features/diary/presentation/providers/diary_provider.dart'; import 'package:eva_mobile/theme/app_colors.dart'; import 'package:eva_mobile/widgets/app_back_button.dart'; @@ -24,7 +24,6 @@ class VoiceSessionDetailScreen extends ConsumerStatefulWidget { } class _VoiceSessionDetailScreenState extends ConsumerState { - final VoiceCallRepository _voiceCallRepo = VoiceCallRepository(); Map? _sessionData; Timer? _pollingTimer; int _pollCount = 0; @@ -83,12 +82,29 @@ class _VoiceSessionDetailScreenState extends ConsumerState _pollUpdatedSessionData() async { final sessionId = _sessionData?['id']?.toString(); if (sessionId != null && sessionId.isNotEmpty) { - final updated = await _voiceCallRepo.fetchConversationById(sessionId); + final chatRepo = ref.read(chatRepositoryProvider); + final updated = await chatRepo.getConversationById(sessionId); if (updated != null && mounted) { - final newTitle = updated['title']?.toString() ?? ''; + final newTitle = updated.title; setState(() { _sessionData?['title'] = newTitle; - _sessionData?['is_title_generating'] = updated['is_title_generating']; + if (updated.summary.isNotEmpty) { + _sessionData?['summary'] = updated.summary; + } + if (updated.messages.isNotEmpty) { + final raw = _sessionData?['transcript']; + if (raw == null || (raw is List && raw.isEmpty)) { + _sessionData?['transcript'] = updated.messages + .map((m) => { + 'role': m.isUser ? 'user' : 'assistant', + 'content': m.text, + 'time': m.time, + 'emotionTag': m.emotionTag, + 'emotionEmoji': m.emotionEmoji, + }) + .toList(); + } + } }); if (!_isTitleLoading(newTitle)) { @@ -97,22 +113,21 @@ class _VoiceSessionDetailScreenState extends ConsumerState>() ?? []; - 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; + final conversations = await chatRepo.getConversations(); + final match = conversations.where((it) => it.id == sessionId).firstOrNull; + if (match != null && mounted) { + final newTitle = match.title; + setState(() { + _sessionData?['title'] = newTitle; + if (match.summary.isNotEmpty) { + _sessionData?['summary'] = match.summary; + } + }); + if (!_isTitleLoading(newTitle)) { + _pollingTimer?.cancel(); + _pollingTimer = null; + } } } } diff --git a/apps/eva_mobile/lib/main.dart b/apps/eva_mobile/lib/main.dart index dc492df..e74ee03 100644 --- a/apps/eva_mobile/lib/main.dart +++ b/apps/eva_mobile/lib/main.dart @@ -36,6 +36,3 @@ class EvaApp extends StatelessWidget { ); } } - -/// Backwards-compatible alias for existing tests -typedef LunaApp = EvaApp; diff --git a/apps/eva_mobile/lib/services/audio_recorder_service.dart b/apps/eva_mobile/lib/services/audio_recorder_service.dart deleted file mode 100644 index 5d97dee..0000000 --- a/apps/eva_mobile/lib/services/audio_recorder_service.dart +++ /dev/null @@ -1,47 +0,0 @@ -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/stt_service.dart b/apps/eva_mobile/lib/services/stt_service.dart deleted file mode 100644 index d740d7b..0000000 --- a/apps/eva_mobile/lib/services/stt_service.dart +++ /dev/null @@ -1,136 +0,0 @@ -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); - } - } - }, - 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 deleted file mode 100644 index 2cd21f7..0000000 --- a/apps/eva_mobile/lib/services/vad_audio_service.dart +++ /dev/null @@ -1,215 +0,0 @@ -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( - 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/widgets/emergency_contact_bottom_sheet.dart b/apps/eva_mobile/lib/widgets/emergency_contact_bottom_sheet.dart deleted file mode 100644 index be3c6ff..0000000 --- a/apps/eva_mobile/lib/widgets/emergency_contact_bottom_sheet.dart +++ /dev/null @@ -1,549 +0,0 @@ - -import 'package:flutter/material.dart'; - -import 'package:google_fonts/google_fonts.dart'; - -import '../theme/app_colors.dart'; -import 'app_snack_bar.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: () { - AppSnackBar.showError( - context, - 'Menghubungi ${_nameController.text.trim()} (${_phoneController.text.trim()})...', - icon: Icons.phone_in_talk_rounded, - ); - }, - - ), - - const SizedBox(height: 12), - - ], - - ), - ), - ); - } - -} - diff --git a/apps/eva_mobile/lib/widgets/eva_loading_orb.dart b/apps/eva_mobile/lib/widgets/eva_loading_orb.dart index 0fad757..05181d3 100644 --- a/apps/eva_mobile/lib/widgets/eva_loading_orb.dart +++ b/apps/eva_mobile/lib/widgets/eva_loading_orb.dart @@ -300,5 +300,3 @@ class _EvaOrbFluidPainter extends CustomPainter { } } -/// Backwards-compatible alias for legacy screens. -typedef LunaLoadingOrb = EvaLoadingOrb; diff --git a/apps/eva_mobile/lib/widgets/luna_loading_orb.dart b/apps/eva_mobile/lib/widgets/luna_loading_orb.dart deleted file mode 100644 index 41c7904..0000000 --- a/apps/eva_mobile/lib/widgets/luna_loading_orb.dart +++ /dev/null @@ -1,2 +0,0 @@ -// Backward compatibility export -export 'eva_loading_orb.dart'; diff --git a/apps/eva_mobile/lib/widgets/mood_checkin_bottom_sheet.dart b/apps/eva_mobile/lib/widgets/mood_checkin_bottom_sheet.dart deleted file mode 100644 index f3017ae..0000000 --- a/apps/eva_mobile/lib/widgets/mood_checkin_bottom_sheet.dart +++ /dev/null @@ -1,797 +0,0 @@ - -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/pubspec.yaml b/apps/eva_mobile/pubspec.yaml index 41a1b7b..6f576bc 100644 --- a/apps/eva_mobile/pubspec.yaml +++ b/apps/eva_mobile/pubspec.yaml @@ -57,7 +57,6 @@ flutter: assets: - assets/images/ - assets/images/eva_logo.png - - assets/images/luna_logo.png - assets/animations/ - assets/audio/ diff --git a/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart b/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart index 5de55e0..96ab9de 100644 --- a/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart +++ b/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart @@ -13,11 +13,11 @@ void main() { home: Scaffold( body: Center( child: BrandedGuidanceCard( - title: 'Cerita ke Luna, yuk!', + title: 'Cerita ke Eva, yuk!', description: 'Tekan tombol tengah untuk mulai berbicara. Kamu bisa mulai saat sudah siap.', actionHint: 'Ketuk tombol di bawah untuk mencoba', - badgeText: 'LUNA AI', + badgeText: 'EVA AI', onSkip: () => skipped = true, ), ), @@ -26,13 +26,13 @@ void main() { ); // Verify title & description - expect(find.text('Cerita ke Luna, yuk!'), findsOneWidget); + expect(find.text('Cerita ke Eva, yuk!'), findsOneWidget); expect( find.text( 'Tekan tombol tengah untuk mulai berbicara. Kamu bisa mulai saat sudah siap.'), findsOneWidget, ); - expect(find.text('LUNA AI'), findsOneWidget); + expect(find.text('EVA AI'), findsOneWidget); expect(find.text('Ketuk tombol di bawah untuk mencoba'), findsOneWidget); expect(find.text('Lewati'), findsOneWidget); diff --git a/apps/eva_mobile/test/features/tutorial/interactive_spotlight_overlay_test.dart b/apps/eva_mobile/test/features/tutorial/interactive_spotlight_overlay_test.dart index ba7226e..1b5ba04 100644 --- a/apps/eva_mobile/test/features/tutorial/interactive_spotlight_overlay_test.dart +++ b/apps/eva_mobile/test/features/tutorial/interactive_spotlight_overlay_test.dart @@ -66,7 +66,7 @@ void main() { await tester.pump(const Duration(milliseconds: 300)); // Check card content rendered - expect(find.text('Cerita ke Luna, yuk!'), findsOneWidget); + expect(find.text('Cerita ke Eva, yuk!'), findsOneWidget); // Attempt tapping the background button outside the cutout await tester.tap(find.text('Background Button')); diff --git a/apps/eva_mobile/test/features/tutorial/main_shell_tutorial_test.dart b/apps/eva_mobile/test/features/tutorial/main_shell_tutorial_test.dart index 6de987d..b6448bd 100644 --- a/apps/eva_mobile/test/features/tutorial/main_shell_tutorial_test.dart +++ b/apps/eva_mobile/test/features/tutorial/main_shell_tutorial_test.dart @@ -38,10 +38,10 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - // 1. Overlay is visible with Luna tutorial card + // 1. Overlay is visible with Eva tutorial card expect(find.byType(InteractiveSpotlightOverlay), findsOneWidget); - expect(find.text('Cerita ke Luna, yuk!'), findsOneWidget); - expect(find.text('REKOMENDASI LUNA'), findsOneWidget); + expect(find.text('Cerita ke Eva, yuk!'), findsOneWidget); + expect(find.text('REKOMENDASI EVA'), findsOneWidget); // 2. Tap the central action button (Icons.auto_awesome) directly through the spotlight cutout await tester.tap(find.byIcon(Icons.auto_awesome).last); diff --git a/apps/eva_mobile/test/features/voice_call/ai_call_controller_test.dart b/apps/eva_mobile/test/features/voice_call/ai_call_controller_test.dart index 8936e9f..59ea999 100644 --- a/apps/eva_mobile/test/features/voice_call/ai_call_controller_test.dart +++ b/apps/eva_mobile/test/features/voice_call/ai_call_controller_test.dart @@ -344,11 +344,11 @@ void main() { controller.handleSocketEvent({ 'type': 'turn.committed', - 'final_transcript': 'halo luna apa kabar', + 'final_transcript': 'halo eva apa kabar', }); expect(controller.state.callState, CallState.thinking); - expect(controller.state.currentTranscript, 'halo luna apa kabar'); + expect(controller.state.currentTranscript, 'halo eva apa kabar'); expect(fakeSpeech.isListening, isFalse); }); diff --git a/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart b/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart index 58d56e6..ea927c7 100644 --- a/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart +++ b/apps/eva_mobile/test/screens/ai_diary_detail_screen_test.dart @@ -40,7 +40,7 @@ void main() { { 'isUser': true, 'time': '09:15 AM', - 'text': 'Halo Luna, aku merasa lelah dengan deadline pekerjaan.', + 'text': 'Halo Eva, aku merasa lelah dengan deadline pekerjaan.', 'emotionTag': 'lelah (80%)', 'emotionEmoji': '💥', }, @@ -85,7 +85,7 @@ void main() { expect(find.text('09:16 AM'), findsNothing); // Transcript message text is present - expect(find.text('Halo Luna, aku merasa lelah dengan deadline pekerjaan.'), findsOneWidget); + expect(find.text('Halo Eva, aku merasa lelah dengan deadline pekerjaan.'), findsOneWidget); expect(find.text('Aku mendengarkanmu. Mari kita urai bebanmu satu per satu.'), findsOneWidget); }); @@ -115,7 +115,7 @@ void main() { 'moodTag': 'Tenang', 'moodEmoji': '😌', 'transcripts': [ - {'isUser': true, 'time': '08:00 AM', 'text': 'Pagi Luna'} + {'isUser': true, 'time': '08:00 AM', 'text': 'Pagi Eva'} ], }, { @@ -125,7 +125,7 @@ void main() { 'moodTag': 'Lega', 'moodEmoji': '😃', 'transcripts': [ - {'isUser': true, 'time': '05:00 PM', 'text': 'Sore Luna'} + {'isUser': true, 'time': '05:00 PM', 'text': 'Sore Eva'} ], }, ], diff --git a/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart b/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart index 8a29500..b25e8cb 100644 --- a/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart +++ b/apps/eva_mobile/test/screens/dass_assessment_screen_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:eva_mobile/core/config/app_config.dart'; +import 'package:eva_mobile/config/app_config.dart'; import 'package:eva_mobile/features/dass/presentation/screens/dass_assessment_screen.dart'; void main() { diff --git a/apps/eva_mobile/test/widgets/luna_loading_orb_test.dart b/apps/eva_mobile/test/widgets/luna_loading_orb_test.dart deleted file mode 100644 index f57ec9d..0000000 --- a/apps/eva_mobile/test/widgets/luna_loading_orb_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:eva_mobile/widgets/luna_loading_orb.dart'; - -void main() { - testWidgets('LunaLoadingOrb renders with halo and custom status message', (tester) async { - await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: LunaLoadingOrb( - size: 200, - message: 'Memuat ruang tenangmu...', - showHalo: true, - ), - ), - ), - ); - - expect(find.byType(LunaLoadingOrb), findsOneWidget); - expect(find.text('Memuat ruang tenangmu...'), findsOneWidget); - - // Pump animated frames - await tester.pump(const Duration(milliseconds: 300)); - await tester.pump(const Duration(milliseconds: 600)); - - expect(find.byType(LunaLoadingOrb), findsOneWidget); - }); -} From f90fc0055cdb141b5a1ae087123fddd95f6636ed Mon Sep 17 00:00:00 2001 From: ekyaaa Date: Sun, 8 Nov 2026 09:14:27 +0700 Subject: [PATCH 4/7] fix(ai): add resilient voice fallback in EdgeTTS provider for voice call (#28) --- packages/ai/providers/tts/edge_tts_provider.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ai/providers/tts/edge_tts_provider.py b/packages/ai/providers/tts/edge_tts_provider.py index 1c822a0..a543c77 100644 --- a/packages/ai/providers/tts/edge_tts_provider.py +++ b/packages/ai/providers/tts/edge_tts_provider.py @@ -30,7 +30,10 @@ async def synthesize_with_envelope( voice_id: str | None = None, voice_settings: dict[str, float] | None = None, ) -> tuple[bytes, list[float]]: + # EdgeTTS requires voices like 'id-ID-GadisNeural'. If an ElevenLabs voice ID or invalid voice is passed, fallback to default. voice = voice_id or self.default_voice + if "-" not in voice or not voice.endswith("Neural"): + voice = self.default_voice try: import edge_tts except ImportError: @@ -87,6 +90,8 @@ async def synthesize_stream( self, text_stream: AsyncGenerator[str, None], voice_id: str | None = None ) -> AsyncGenerator[bytes, None]: voice = voice_id or self.default_voice + if "-" not in voice or not voice.endswith("Neural"): + voice = self.default_voice try: import edge_tts except ImportError: From 18b9eb9eeeeaee0715edfb0ae0c3d0ea99bcba15 Mon Sep 17 00:00:00 2001 From: ekyaaa Date: Sun, 8 Nov 2026 09:15:44 +0700 Subject: [PATCH 5/7] refactor(backend): clean code architecture modularization and thin routes (#27) --- .agents/rules/backend-clean-architecture.md | 171 ++++++ apps/backend/AGENTS.md | 30 +- apps/backend/api/app/api/dependencies.py | 98 +++ apps/backend/api/app/api/routes/analytics.py | 62 +- apps/backend/api/app/api/routes/auth.py | 276 ++------- apps/backend/api/app/api/routes/call.py | 64 +- .../api/app/api/routes/conversations.py | 568 ++---------------- apps/backend/api/app/api/routes/dass.py | 131 +--- apps/backend/api/app/api/routes/diaries.py | 422 ++----------- apps/backend/api/app/api/routes/health.py | 2 +- .../api/app/api/routes/recommendations.py | 461 +------------- apps/backend/api/app/api/routes/user.py | 215 ++----- .../api/app/schemas/analytics/__init__.py | 11 + .../api/app/schemas/analytics/responses.py | 33 + apps/backend/api/app/schemas/auth/__init__.py | 17 + apps/backend/api/app/schemas/auth/requests.py | 16 + .../backend/api/app/schemas/auth/responses.py | 26 + .../api/app/schemas/conversation/__init__.py | 31 + .../api/app/schemas/conversation/requests.py | 6 + .../api/app/schemas/conversation/responses.py | 117 ++++ apps/backend/api/app/schemas/dass/__init__.py | 14 + apps/backend/api/app/schemas/dass/requests.py | 10 + .../schemas/{dass.py => dass/responses.py} | 13 +- .../backend/api/app/schemas/diary/__init__.py | 20 + .../backend/api/app/schemas/diary/requests.py | 12 + .../api/app/schemas/diary/responses.py | 61 ++ .../api/app/schemas/health/__init__.py | 3 + .../{health.py => health/responses.py} | 4 +- .../app/schemas/recommendation/__init__.py | 11 + .../app/schemas/recommendation/responses.py | 40 ++ apps/backend/api/app/schemas/user/__init__.py | 15 + apps/backend/api/app/schemas/user/requests.py | 15 + .../backend/api/app/schemas/user/responses.py | 15 + .../api/app/services/analytics/__init__.py | 3 + .../{ => analytics}/analytics_service.py | 10 + .../backend/api/app/services/auth/__init__.py | 3 + .../api/app/services/auth/auth_service.py | 217 +++++++ .../backend/api/app/services/call/__init__.py | 33 + .../persistence.py} | 9 +- .../session_manager.py} | 69 ++- .../stream_buffer.py} | 0 .../services/{ => call}/turn_aggregator.py | 2 +- .../turn_detector.py} | 0 .../api/app/services/conversation/__init__.py | 19 + .../conversation/conversation_service.py | 536 +++++++++++++++++ .../conversation/summary_stream_service.py | 167 +++++ .../api/app/services/conversation_service.py | 188 ------ .../backend/api/app/services/dass/__init__.py | 3 + .../app/services/{ => dass}/dass_service.py | 192 +++--- .../api/app/services/diary/__init__.py | 4 + .../services/{ => diary}/diary_generator.py | 5 +- .../api/app/services/diary/diary_service.py | 358 +++++++++++ .../api/app/services/emotion/__init__.py | 4 + .../analyzer.py} | 0 .../ml_detector.py} | 0 .../app/services/recommendation/__init__.py | 3 + .../recommendation/recommendation_service.py | 398 ++++++++++++ .../api/app/services/status/__init__.py | 3 + .../services/{ => status}/status_service.py | 0 .../backend/api/app/services/user/__init__.py | 3 + .../api/app/services/user/user_service.py | 143 +++++ apps/backend/api/tests/test_call_websocket.py | 24 +- .../api/tests/test_clean_architecture.py | 121 ++++ apps/backend/api/tests/test_dass_routes.py | 2 +- apps/backend/api/tests/test_health.py | 11 + .../api/tests/test_ml_emotion_detector.py | 2 +- .../backend/api/tests/test_turn_aggregator.py | 3 +- 67 files changed, 3265 insertions(+), 2260 deletions(-) create mode 100644 .agents/rules/backend-clean-architecture.md create mode 100644 apps/backend/api/app/schemas/analytics/__init__.py create mode 100644 apps/backend/api/app/schemas/analytics/responses.py create mode 100644 apps/backend/api/app/schemas/auth/__init__.py create mode 100644 apps/backend/api/app/schemas/auth/requests.py create mode 100644 apps/backend/api/app/schemas/auth/responses.py create mode 100644 apps/backend/api/app/schemas/conversation/__init__.py create mode 100644 apps/backend/api/app/schemas/conversation/requests.py create mode 100644 apps/backend/api/app/schemas/conversation/responses.py create mode 100644 apps/backend/api/app/schemas/dass/__init__.py create mode 100644 apps/backend/api/app/schemas/dass/requests.py rename apps/backend/api/app/schemas/{dass.py => dass/responses.py} (85%) create mode 100644 apps/backend/api/app/schemas/diary/__init__.py create mode 100644 apps/backend/api/app/schemas/diary/requests.py create mode 100644 apps/backend/api/app/schemas/diary/responses.py create mode 100644 apps/backend/api/app/schemas/health/__init__.py rename apps/backend/api/app/schemas/{health.py => health/responses.py} (59%) create mode 100644 apps/backend/api/app/schemas/recommendation/__init__.py create mode 100644 apps/backend/api/app/schemas/recommendation/responses.py create mode 100644 apps/backend/api/app/schemas/user/__init__.py create mode 100644 apps/backend/api/app/schemas/user/requests.py create mode 100644 apps/backend/api/app/schemas/user/responses.py create mode 100644 apps/backend/api/app/services/analytics/__init__.py rename apps/backend/api/app/services/{ => analytics}/analytics_service.py (98%) create mode 100644 apps/backend/api/app/services/auth/__init__.py create mode 100644 apps/backend/api/app/services/auth/auth_service.py create mode 100644 apps/backend/api/app/services/call/__init__.py rename apps/backend/api/app/services/{call_persistence_service.py => call/persistence.py} (97%) rename apps/backend/api/app/services/{call_session_manager.py => call/session_manager.py} (93%) rename apps/backend/api/app/services/{audio_stream_buffer.py => call/stream_buffer.py} (100%) rename apps/backend/api/app/services/{ => call}/turn_aggregator.py (98%) rename apps/backend/api/app/services/{turn_detection_service.py => call/turn_detector.py} (100%) create mode 100644 apps/backend/api/app/services/conversation/__init__.py create mode 100644 apps/backend/api/app/services/conversation/conversation_service.py create mode 100644 apps/backend/api/app/services/conversation/summary_stream_service.py delete mode 100644 apps/backend/api/app/services/conversation_service.py create mode 100644 apps/backend/api/app/services/dass/__init__.py rename apps/backend/api/app/services/{ => dass}/dass_service.py (78%) create mode 100644 apps/backend/api/app/services/diary/__init__.py rename apps/backend/api/app/services/{ => diary}/diary_generator.py (98%) create mode 100644 apps/backend/api/app/services/diary/diary_service.py create mode 100644 apps/backend/api/app/services/emotion/__init__.py rename apps/backend/api/app/services/{emotion_analyzer.py => emotion/analyzer.py} (100%) rename apps/backend/api/app/services/{ml_emotion_detector.py => emotion/ml_detector.py} (100%) create mode 100644 apps/backend/api/app/services/recommendation/__init__.py create mode 100644 apps/backend/api/app/services/recommendation/recommendation_service.py create mode 100644 apps/backend/api/app/services/status/__init__.py rename apps/backend/api/app/services/{ => status}/status_service.py (100%) create mode 100644 apps/backend/api/app/services/user/__init__.py create mode 100644 apps/backend/api/app/services/user/user_service.py create mode 100644 apps/backend/api/tests/test_clean_architecture.py diff --git a/.agents/rules/backend-clean-architecture.md b/.agents/rules/backend-clean-architecture.md new file mode 100644 index 0000000..034723a --- /dev/null +++ b/.agents/rules/backend-clean-architecture.md @@ -0,0 +1,171 @@ +--- +trigger: model_decision +description: Aturan dan standar Clean Architecture, Thin Routers (3-5 baris), modular services (app/services/{module}/), dan schemas (app/schemas/{module}/) untuk backend FastAPI di apps/backend. +--- + +# FastAPI Clean Architecture & Modular Backend Standards + +> **Scope:** `apps/backend/api/**`, `apps/backend/workers/**`, `apps/backend/mcp/**` +> **Tujuan:** Menjamin kode backend selalu menerapkan prinsip *Clean Architecture*, *Single Responsibility Principle* (SRP), *Dependency Injection* (DI), dan pemisahan modular berbasis domain agar setiap route handler sangat bersih (3–5 baris) dan tidak ada lagi *fat controllers* atau duplikasi kode. + +--- + +## 1. Struktur Organisasi Modul (`services/` & `schemas/`) + +Seluruh domain bisnis di `apps/backend/api/app` **wajib** dikelompokkan ke dalam paket folder modular: + +```text +apps/backend/api/app/ +├── api/ +│ ├── dependencies.py # Centralized DI (get_current_user, get_db, service factories) +│ └── routes/ # Thin Route Handlers (3-5 baris per endpoint) +│ ├── auth.py +│ ├── user.py +│ ├── diaries.py +│ ├── recommendations.py +│ ├── conversations.py +│ ├── call.py +│ ├── dass.py +│ ├── analytics.py +│ └── health.py +├── schemas/ # Strict Pydantic Contracts per Modul +│ └── {module}/ +│ ├── __init__.py # Barrel export seluruh model request & response +│ ├── requests.py # Request body & query parameters +│ └── responses.py # Response models (DTO) +├── services/ # Domain Logic & Database Orchestration per Modul +│ └── {module}/ +│ ├── __init__.py # Barrel export service classes +│ ├── {name}_service.py # Implementasi domain service +│ └── ... # Sub-komponen pendukung jika modul berat (heavyweight) +└── models/ # SQLAlchemy Entities (Pure data models & relationships) +``` + +### Aturan Paket & Barrel Export (`__init__.py`): +1. Setiap subfolder di `app/schemas/{module}/` **wajib** memiliki `__init__.py` yang me-re-export semua class request dan response publik. +2. Setiap subfolder di `app/services/{module}/` **wajib** memiliki `__init__.py` yang me-re-export class service publik. +3. Import di level router harus bersih dan ringkas: + ```python + from app.schemas.auth import LoginRequest, TokenResponse + from app.services.auth import AuthService + ``` + +--- + +## 2. Invariant Thin Router (Maksimal 3–5 Baris per Endpoint) + +Router FastAPI (`app/api/routes/*.py`) bertindak murni sebagai **HTTP Transport Controller**. + +### ❌ Dilarang Keras di Router Handler: +1. **Dilarang Inline SQL**: Tidak boleh ada query SQLAlchemy (`select()`, `db.execute()`, `db.commit()`, `db.add()`) di dalam router. Seluruh interaksi database wajib di dalam class service. +2. **Dilarang Inline Business Logic / Calculations**: Kalkulasi persentase emosi, DASS scoring, pemrosesan teks, dan validasi krisis dilarang ditulis di router. +3. **Dilarang Raw Dictionary Returns**: Dilarang mengembalikan `dict[str, Any]` tanpa `response_model`. Seluruh endpoint HTTP REST wajib mendeklarasikan `response_model=...` menggunakan Pydantic schema dari `app/schemas/{module}/` (Kecuali streaming SSE yang mengembalikan `StreamingResponse` dan endpoint `@router.websocket`). +4. **Dilarang Menduplikasi `_get_current_user`**: Dilarang membuat fungsi privat `_get_current_user` di dalam file router. Seluruh autentikasi wajib mengimpor `get_current_user` dari `app.api.dependencies`. +5. **Dilarang Instansiasi Manual**: Dilarang memanggil `AsyncSessionLocal()`, `LLMFactory.get_provider()`, atau class service secara manual di dalam router. Gunakan FastAPI `Depends()`. + +### ✅ Contoh Standar Thin Route (3–5 Baris): + +```python +@router.post("/login", response_model=TokenResponse) +async def login( + payload: LoginRequest, + service: AuthService = Depends(get_auth_service), +) -> TokenResponse: + """Otentikasi pengguna dan terbitkan akses token.""" + return await service.login(payload) + +@router.post("", response_model=DiaryEntryResponse, status_code=status.HTTP_201_CREATED) +async def create_diary( + payload: CreateDiaryRequest, + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> DiaryEntryResponse: + """Simpan refleksi harian pengguna.""" + return await service.create_entry(user_id=user.id, payload=payload) +``` + +### ✅ Contoh Standar Thin WebSocket Route: + +```python +@router.websocket("/ws/{session_id}") +async def websocket_call_endpoint( + websocket: WebSocket, + session_id: str, + token: str | None = None, + voice_mode: str | None = None, +) -> None: + """Delegasikan seluruh handshake, receive loop, turn handling, dan cleanup ke CallSessionManager.""" + await call_session_manager.handle_connection( + websocket=websocket, + session_id=session_id, + token=token, + voice_mode=voice_mode, + ) +``` + +--- + +## 3. Centralized Dependency Injection (`app/api/dependencies.py`) + +Seluruh dependency provider dipusatkan di `app/api/dependencies.py`: + +```python +# 1. Database & Security Providers +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: ... +async def get_current_user(...) -> User: ... + +# 2. Service Factory Providers +async def get_auth_service(db: AsyncSession = Depends(get_db_session)) -> AuthService: + return AuthService(db) + +async def get_user_service(db: AsyncSession = Depends(get_db_session)) -> UserService: + return UserService(db) + +async def get_diary_service(db: AsyncSession = Depends(get_db_session)) -> DiaryService: + return DiaryService(db) +``` + +--- + +## 4. Pemisahan Modul Berat (*Heavyweight Modules*) + +Untuk domain yang memiliki kompleksitas tinggi dan multi-komponen, wajib dipecah menjadi sub-service kolaborator di dalam folder modulnya: + +### A. Modul Panggilan Suara & Real-time (`app/services/call/`) +- `session_manager.py`: Mengelola lifecycle WebSocket (`handle_connection`), state call (`idle`, `speaking`, `interrupted`), dan barge-in. +- `persistence.py`: Menangani penyimpanan sesi percakapan, transkrip turn, dan message duration ke database. +- `stream_buffer.py`: Mengelola buffer byte raw mic audio incoming. +- `turn_aggregator.py`: Mengagregasi turn pengguna dan deteksi completion. +- `turn_detector.py`: Evaluasi acoustic energy (RMS) dan sentence completion. + +### B. Modul Deteksi Emosi (`app/services/emotion/`) +- `ml_detector.py`: Inferensi machine learning klasifikasi emosi suara/teks. +- `analyzer.py`: Heuristik fallback dan mapper bobot 7-emosi ke UI. + +### C. Modul Percakapan & Diary (`app/services/conversation/` & `app/services/diary/`) +- Pisahkan operasi streaming SSE (misal ringkasan LLM token-by-token) ke dalam `summary_stream_service.py`. +- Pisahkan sintesis AI harian ke dalam `diary_generator.py` dan CRUD database ke dalam `diary_service.py`. + +--- + +## 5. Kontrak Data Pydantic (Request & Response) + +1. Semua data yang keluar dari API wajib melalui model Pydantic di `app/schemas/{module}/responses.py`. +2. Model Pydantic wajib mengaktifkan `from_attributes = True` jika membaca langsung dari model SQLAlchemy ORM: + ```python + class ConfigDict: + from_attributes = True + ``` +3. Naming convention: + - Request models berakhiran `Request` (contoh: `CreateDiaryRequest`, `RegisterRequest`). + - Response models berakhiran `Response` atau `Item` (contoh: `DiaryEntryResponse`, `TokenResponse`, `MessageItem`). + - Query filter models berakhiran `Filter` atau `Params` (contoh: `ConversationQueryParams`). + +--- + +## 6. Protokol Verifikasi & Anti-Regression + +Sebelum menyatakan pekerjaan refaktor atau pembuatan API baru selesai: +1. Jalankan `make test-backend` untuk memastikan semua endpoint lolos uji integritas. +2. Pastikan tidak ada `dict[str, Any]` yang tersisa sebagai tipe return di fungsi router. +3. Jalankan `make lint` dan `make format` untuk menjamin kerapian kode sesuai standar Ruff. diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 2cd7416..dfe17ae 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -1,13 +1,16 @@ # Backend (FastAPI, MCP & Workers) - Sub-Workspace Rules -> Scope: `apps/backend/**` +> Scope: `apps/backend/**` +> Mandat Arsitektur: Wajib mematuhi [.agents/rules/backend-clean-architecture.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/backend-clean-architecture.md) ## 1. Directory & Code Organization - **API (`apps/backend/api`)**: - - Routes in `app/api/routes` - - SQLAlchemy models in `app/models`, Pydantic schemas in `app/schemas` - - Domain business services in `app/services` - - Database sessions and engine in `app/db` + - **Routes (`app/api/routes`)**: Thin Controllers (3–5 baris per endpoint). Hanya mendeklarasikan HTTP path, `response_model`, dan delegasi ke service. + - **Centralized Dependencies (`app/api/dependencies.py`)**: Seluruh dependency (`get_current_user`, `get_db_session`, service factories) dipusatkan di sini. Dilarang duplikasi `_get_current_user` di file route. + - **Modular Schemas (`app/schemas/{module}/`)**: Kontrak Pydantic per domain (`requests.py`, `responses.py`, dan `__init__.py` barrel export). Dilarang mengembalikan `dict[str, Any]` tanpa Pydantic schema. + - **Modular Domain Services (`app/services/{module}/`)**: Logika bisnis, transaksi database, dan orkestrasi per domain (`{name}_service.py` dan `__init__.py`). + - **Database Models (`app/models`)**: SQLAlchemy ORM models dan declarative base. + - **Database Sessions & Seeders (`app/db`)**: Engine konfigurasi dan master data seeding. - **MCP (`apps/backend/mcp`)**: - FastMCP tool definitions in `app/tools` - Completely decoupled from direct HTTP router dependencies. @@ -15,17 +18,24 @@ - Async ARQ task definitions in `app/tasks` and worker loop in `app/workers`. ## 2. Abstraction & Dependency Injection -- Never instantiate database sessions or AI provider classes directly inside route handlers. +- Never instantiate database sessions (`AsyncSessionLocal()`), AI provider classes (`LLMFactory`), or services directly inside route handlers. - Always inject via FastAPI `Depends()`: + - User Identity: `user: User = Depends(get_current_user)` (dari `app.api.dependencies`) - Database: `db: AsyncSession = Depends(get_db_session)` + - Domain Services: `service: AuthService = Depends(get_auth_service)` - AI Providers: `llm: BaseLLMProvider = Depends(get_llm_provider)` - Services must be decoupled from FastAPI request/response lifecycle where possible to allow worker reuse. -## 3. Database & Migrations +## 3. Thin Router Invariant (3–5 Baris per Endpoint) +- Dilarang menulis raw SQL queries (`select()`, `db.execute()`, `db.commit()`) di dalam router handler. +- Dilarang melakukan kalkulasi emosi, validasi password manual, atau pembuatan token JWT di dalam router handler. +- Router hanya bertugas menerima request, memicu method service, dan mengembalikan response model Pydantic yang valid. + +## 4. Database & Migrations - Use Alembic for all schema changes (`alembic revision --autogenerate -m "..."`). - Never perform raw schema alterations in production migrations. - Test queries using async SQLAlchemy patterns (`select()`, `scalars()`). -## 4. Service Cohesion & Separation of Concerns -- Separate real-time event dispatching (WebSockets, streaming audio) from database persistence transactions. -- Keep route handlers focused on validation and delegating to services without inline SQL queries. +## 5. Service Cohesion & Separation of Concerns +- Modul berat (*heavyweight modules*) seperti voice call (`app/services/call/`) wajib memisahkan I/O real-time (WebSockets, audio streaming, turn detection) dari transaksi persistensi database (`persistence.py`). Seluruh koneksi WebSocket di router wajib didelegasikan langsung ke `call_session_manager.handle_connection`. +- Modul emosi (`app/services/emotion/`) wajib memisahkan detektor ML (`ml_detector.py`) dari heuristik fallback / formatting (`analyzer.py`). diff --git a/apps/backend/api/app/api/dependencies.py b/apps/backend/api/app/api/dependencies.py index a27bc5f..e25ab1a 100644 --- a/apps/backend/api/app/api/dependencies.py +++ b/apps/backend/api/app/api/dependencies.py @@ -1,8 +1,16 @@ from collections.abc import AsyncGenerator +import logging +from fastapi import Depends, Header, HTTPException, Query, status from redis.asyncio import Redis, from_url +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_db_session +from app.models.user import User from shared.config import settings +logger = logging.getLogger(__name__) + async def get_redis_client() -> AsyncGenerator[Redis, None]: """Dependency for providing Async Redis client.""" @@ -11,3 +19,93 @@ async def get_redis_client() -> AsyncGenerator[Redis, None]: yield client finally: await client.close() + + +def extract_auth_token( + authorization: str | None = Header(None), + token_query: str | None = Query(None, alias="token"), +) -> str: + """Extract raw bearer token from Authorization header or 'token' query parameter.""" + token = None + if authorization and authorization.startswith("Bearer "): + token = authorization.split("Bearer ")[1].strip() + elif token_query: + token = token_query.strip() + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Header Authorization Bearer atau token query diperlukan", + ) + return token + + +async def get_auth_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for AuthService.""" + from app.services.auth import AuthService + + return AuthService(db) + + +async def get_current_user( + token: str = Depends(extract_auth_token), + service=Depends(get_auth_service), +) -> User: + """Strictly authenticate current user from Bearer JWT access token or query parameter.""" + return await service.get_user_from_token(token) + + + +async def get_user_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for UserService.""" + from app.services.user import UserService + + return UserService(db) + + +async def get_diary_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for DiaryService.""" + from app.services.diary import DiaryService + + return DiaryService(db) + + +async def get_recommendation_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for RecommendationService.""" + from app.services.recommendation import RecommendationService + + return RecommendationService(db) + + +async def get_dass_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for DASSService.""" + from app.services.dass import DASSService + + return DASSService(db) + + +async def get_analytics_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for AnalyticsService.""" + from app.services.analytics import AnalyticsService + + return AnalyticsService(db) + + +async def get_conversation_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for ConversationService.""" + from app.services.conversation import ConversationService + + return ConversationService(db) + + +async def get_summary_stream_service(db: AsyncSession = Depends(get_db_session)): + """Dependency factory for SummaryStreamService.""" + from app.services.conversation import SummaryStreamService + + return SummaryStreamService(db) + + + + + + diff --git a/apps/backend/api/app/api/routes/analytics.py b/apps/backend/api/app/api/routes/analytics.py index 613c77a..1cab3ab 100644 --- a/apps/backend/api/app/api/routes/analytics.py +++ b/apps/backend/api/app/api/routes/analytics.py @@ -1,66 +1,22 @@ import logging -from typing import Any -import uuid -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends, Query -from app.core.security import decode_access_token -from app.db.session import get_db_session +from app.api.dependencies import get_analytics_service, get_current_user from app.models.user import User -from app.services.analytics_service import AnalyticsService +from app.schemas.analytics import MonitoringDataResponse +from app.services.analytics import AnalyticsService logger = logging.getLogger(__name__) router = APIRouter(prefix="/analytics", tags=["Analytics"]) -async def _get_current_user( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> User: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - -@router.get("/monitoring") +@router.get("/monitoring", response_model=MonitoringDataResponse) async def get_monitoring_data( period: str = Query("today", description="Period: today | week | month"), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: + user: User = Depends(get_current_user), + service: AnalyticsService = Depends(get_analytics_service), +) -> MonitoringDataResponse: """Fetch emotional rhythm, mental health risks, and AI summary for the requested period.""" - return await AnalyticsService.get_monitoring_data(user.id, period, db) - - + return await service.get_data(user.id, period=period) diff --git a/apps/backend/api/app/api/routes/auth.py b/apps/backend/api/app/api/routes/auth.py index d4b0bab..e2e82c6 100644 --- a/apps/backend/api/app/api/routes/auth.py +++ b/apps/backend/api/app/api/routes/auth.py @@ -1,233 +1,73 @@ import logging -from typing import Any -import uuid - -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status -from pydantic import BaseModel, EmailStr -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.security import ( - create_access_token, - create_refresh_token, - decode_access_token, - decode_refresh_token, - hash_password, - verify_password, -) -from app.db.session import get_db_session + +from fastapi import APIRouter, Depends, Query, status + +from app.api.dependencies import get_auth_service, get_current_user from app.models.user import User +from app.schemas.auth import ( + EmailCheckResponse, + LoginRequest, + LogoutResponse, + RefreshRequest, + RegisterRequest, + TokenResponse, + UserAuthResponse, +) +from app.services.auth import AuthService logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["Auth"]) -class LoginRequest(BaseModel): - email: EmailStr - password: str +@router.get("/check-email", response_model=EmailCheckResponse) +async def check_email( + email: str = Query(..., description="Email address to check"), + service: AuthService = Depends(get_auth_service), +) -> EmailCheckResponse: + """Check if an email address is available for registration.""" + return await service.check_email(email) + + +@router.post("/login", response_model=TokenResponse) +async def login( + payload: LoginRequest, + service: AuthService = Depends(get_auth_service), +) -> TokenResponse: + """Authenticate user and issue JWT access & refresh tokens.""" + return await service.login(payload) -class RegisterRequest(BaseModel): - name: str - email: EmailStr - password: str +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def register( + payload: RegisterRequest, + service: AuthService = Depends(get_auth_service), +) -> TokenResponse: + """Register a new user account and return initial tokens.""" + return await service.register(payload) -class RefreshRequest(BaseModel): - refresh_token: str +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token( + payload: RefreshRequest, + service: AuthService = Depends(get_auth_service), +) -> TokenResponse: + """Refresh an expired access token using a valid refresh token.""" + return await service.refresh_token(payload) -@router.get("/check-email") -async def check_email( - email: str = Query(..., description="Email address to check"), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - """Check if an email address is available for registration.""" - clean_email = email.strip().lower() - if not clean_email or "@" not in clean_email or "." not in clean_email: - return { - "available": False, - "email": clean_email, - "message": "Format email tidak valid", - } - - query = select(User).where(User.email == clean_email) - res = await db.execute(query) - existing = res.scalar_one_or_none() - - if existing: - return { - "available": False, - "email": clean_email, - "message": "Email sudah terdaftar", - } - - return { - "available": True, - "email": clean_email, - "message": "Email tersedia", - } - - -@router.post("/login") -async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db_session)) -> dict[str, Any]: - clean_email = payload.email.strip().lower() - query = select(User).where(User.email == clean_email) - res = await db.execute(query) - user = res.scalar_one_or_none() - - if not user or not verify_password(payload.password, user.password_hash): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Email atau kata sandi tidak valid", - ) - - display_name = user.display_name or user.username or "User Eva" - access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) - refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) - - return { - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": "bearer", - "user": { - "id": str(user.id), - "name": display_name, - "email": user.email, - }, - } - - -@router.post("/register") -async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db_session)) -> dict[str, Any]: - clean_email = payload.email.strip().lower() - query = select(User).where(User.email == clean_email) - res = await db.execute(query) - existing = res.scalar_one_or_none() - - if existing: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email sudah terdaftar", - ) - - clean_name = payload.name.strip() - user = User( - email=clean_email, - username=clean_name, - display_name=clean_name, - password_hash=hash_password(payload.password), - is_active=True, - is_verified=True, - ) - db.add(user) - await db.commit() - await db.refresh(user) - - display_name = user.display_name or clean_name - access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) - refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) - - return { - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": "bearer", - "user": { - "id": str(user.id), - "name": display_name, - "email": user.email, - }, - } - - -@router.post("/refresh") -async def refresh_token(payload: RefreshRequest, db: AsyncSession = Depends(get_db_session)) -> dict[str, Any]: - decoded = decode_refresh_token(payload.refresh_token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except ValueError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID token tidak valid", - ) - - query = select(User).where(User.id == u_uuid) - res = await db.execute(query) - user = res.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User tidak ditemukan atau tidak aktif", - ) - - display_name = user.display_name or user.username or "User Eva" - new_access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) - new_refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) - - return { - "access_token": new_access_token, - "refresh_token": new_refresh_token, - "token_type": "bearer", - "user": { - "id": str(user.id), - "name": display_name, - "email": user.email, - }, - } - - -@router.get("/me") +@router.get("/me", response_model=UserAuthResponse) async def get_me( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan", - ) - - return { - "id": str(user.id), - "name": user.display_name or user.username or "User Eva", - "email": user.email, - } - - -@router.post("/logout") -async def logout() -> dict[str, str]: - return {"message": "Logged out successfully"} + current_user: User = Depends(get_current_user), + service: AuthService = Depends(get_auth_service), +) -> UserAuthResponse: + """Get the currently authenticated user's profile.""" + return service.get_user_profile(current_user) + + +@router.post("/logout", response_model=LogoutResponse) +async def logout( + service: AuthService = Depends(get_auth_service), +) -> LogoutResponse: + """Acknowledge user logout.""" + return service.logout() diff --git a/apps/backend/api/app/api/routes/call.py b/apps/backend/api/app/api/routes/call.py index 4689e9f..098b566 100644 --- a/apps/backend/api/app/api/routes/call.py +++ b/apps/backend/api/app/api/routes/call.py @@ -1,10 +1,8 @@ -import json import logging -from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, WebSocket -from app.services.audio_stream_buffer import audio_buffer_service -from app.services.call_session_manager import call_session_manager +from app.services.call import call_session_manager logger = logging.getLogger(__name__) @@ -18,54 +16,10 @@ async def websocket_call_endpoint( token: str | None = None, voice_mode: str | None = None, ) -> None: - """Bi-directional WebSocket endpoint for AI Phone Call sessions. - - Supports: - - Binary frames: Incoming raw microphone audio bytes (forwarded to audio_buffer_service). - - Text/JSON frames: Control events (`start_call`, `user_transcript`, `user_interrupted`, `end_call`, `ping`). - """ - await websocket.accept() - session = call_session_manager.register_session(session_id, websocket, auth_token=token, voice_mode=voice_mode) - logger.info(f"🔌 [WEBSOCKET CONNECTED] Session: {session_id} (Voice Mode: {session.voice_mode})") - - try: - # Pre-load previous conversation history from DB so DeepSeek LLM remembers past context - await call_session_manager.load_conversation_history(session) - - # Notify client connection accepted - await websocket.send_json({ - "type": "call_connected", - "session_id": session_id, - "message": "Connected to Eva AI Phone Service", - }) - - while True: - message = await websocket.receive() - if "bytes" in message and message["bytes"]: - audio_bytes = message["bytes"] - logger.info(f"🎙️ [INCOMING MIC AUDIO] Session {session_id}: Received {len(audio_bytes)} raw mic bytes") - await audio_buffer_service.handle_incoming_audio_chunk(session_id, audio_bytes) - - elif "text" in message and message["text"]: - try: - payload = json.loads(message["text"]) - event_type = payload.get("type") - - if event_type == "end_call": - duration_sec = payload.get("duration_seconds") - logger.info(f"🛑 [CALL ENDED BY USER] Session {session_id}, Duration: {duration_sec}s") - await call_session_manager.update_session_end(session_id, duration_seconds=duration_sec) - break - - await call_session_manager.handle_event(session, payload) - - except json.JSONDecodeError: - logger.warning(f"⚠️ [INVALID JSON] Session {session_id}: Received malformed JSON") - - except WebSocketDisconnect: - logger.info(f"🔌 [WEBSOCKET DISCONNECTED] Session {session_id}") - except Exception as e: - logger.error(f"❌ [WEBSOCKET ERROR] Session {session_id}: {e}") - finally: - call_session_manager.unregister_session(session_id) - audio_buffer_service.clear_session(session_id) + """Bi-directional WebSocket endpoint for AI Phone Call sessions.""" + await call_session_manager.handle_connection( + websocket=websocket, + session_id=session_id, + token=token, + voice_mode=voice_mode, + ) diff --git a/apps/backend/api/app/api/routes/conversations.py b/apps/backend/api/app/api/routes/conversations.py index 51ab33d..2a12621 100644 --- a/apps/backend/api/app/api/routes/conversations.py +++ b/apps/backend/api/app/api/routes/conversations.py @@ -1,557 +1,87 @@ -import json -import math -import uuid -from collections import Counter -from datetime import UTC, datetime, date, time, timezone - import logging -from typing import Any +import uuid -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from fastapi import APIRouter, Depends, Query from fastapi.responses import StreamingResponse -from pydantic import BaseModel -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload -from app.core.security import decode_access_token -from app.core.tz import get_wib_day_range_utc, get_wib_today, to_wib -from app.db.session import AsyncSessionLocal, get_db_session -from app.models.conversation import Conversation, Message, ConversationSummary -from app.models.diary import DiaryEntry -from app.models.enums import ConversationStatus, MessageType -from app.models.safety import EmotionAnalysis +from app.api.dependencies import ( + get_conversation_service, + get_current_user, + get_summary_stream_service, +) from app.models.user import User -from app.services.conversation_service import ( - _analyze_message_emotion_fallback, - _format_conversation, - _format_duration_str, - _format_time_local, - _get_message_emotion, +from app.schemas.conversation import ( + ConversationDetailResponse, + ConversationListResponse, + SendMessageRequest, + SentMessageResponse, + TodayConversationResponse, + TodayMessagesResponse, ) -from app.services.ml_emotion_detector import MLEmotionDetectorService -from packages.ai.factories.llm_factory import LLMFactory -from packages.ai.interfaces.llm import LLMMessage +from app.services.conversation import ConversationService, SummaryStreamService logger = logging.getLogger(__name__) router = APIRouter(prefix="/conversations", tags=["Conversations"]) -class SendMessageRequest(BaseModel): - content: str - modality: str = "text" - - -async def _get_current_user( - authorization: str | None = Header(None), - token_query: str | None = Query(None, alias="token"), - db: AsyncSession = Depends(get_db_session), -) -> User: - token = None - if authorization and authorization.startswith("Bearer "): - token = authorization.split("Bearer ")[1].strip() - elif token_query: - token = token_query.strip() - - if not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer atau token query diperlukan", - ) - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - -@router.get("") +@router.get("", response_model=ConversationListResponse) async def get_conversations( page: int = Query(1, ge=1, description="Page number for pagination"), limit: int = Query(10, ge=1, le=100, description="Items per page"), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - count_query = ( - select(func.count()) - .select_from(Conversation) - .where(Conversation.user_id == user.id) - ) - total_res = await db.execute(count_query) - total_items = total_res.scalar() or 0 - - offset = (page - 1) * limit - query = ( - select(Conversation) - .options( - selectinload(Conversation.messages), - selectinload(Conversation.summaries), - ) - .where(Conversation.user_id == user.id) - .order_by(Conversation.started_at.desc()) - .offset(offset) - .limit(limit) - ) - res = await db.execute(query) - convs = res.scalars().all() - - message_ids = [m.id for c in convs for m in c.messages] - emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} - if message_ids: - emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) - emo_res = await db.execute(emo_query) - for emo in emo_res.scalars().all(): - emotion_map[emo.message_id] = emo + user: User = Depends(get_current_user), + service: ConversationService = Depends(get_conversation_service), +) -> ConversationListResponse: + """Retrieve paginated conversations with messages and emotion mappings.""" + return await service.get_conversations(user.id, page=page, limit=limit) - formatted_items = [_format_conversation(c, emotion_map) for c in convs] - total_pages = max(1, math.ceil(total_items / limit)) if total_items > 0 else 1 - has_more = (offset + len(convs)) < total_items - return { - "items": formatted_items, - "pagination": { - "page": page, - "limit": limit, - "total_items": total_items, - "total_pages": total_pages, - "has_more": has_more, - }, - "total": total_items, - "page": page, - "limit": limit, - "has_more": has_more, - } - - -@router.get("/today") +@router.get("/today", response_model=TodayConversationResponse) async def get_today_conversations( page: int = Query(1, ge=1, description="Page number for pagination"), limit: int = Query(10, ge=1, le=100, description="Items per page"), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: + user: User = Depends(get_current_user), + service: ConversationService = Depends(get_conversation_service), +) -> TodayConversationResponse: """Retrieve today's voice call sessions with local time formatting and backend pagination.""" - today_wib = get_wib_today() - start_utc, end_utc = get_wib_day_range_utc(today_wib) - - # Count total today conversations - count_query = ( - select(func.count()) - .select_from(Conversation) - .where( - Conversation.user_id == user.id, - Conversation.started_at >= start_utc, - Conversation.started_at <= end_utc, - ) - ) - total_res = await db.execute(count_query) - total_items = total_res.scalar() or 0 - - offset = (page - 1) * limit - query = ( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where( - Conversation.user_id == user.id, - Conversation.started_at >= start_utc, - Conversation.started_at <= end_utc, - ) - .order_by(Conversation.started_at.desc()) - .offset(offset) - .limit(limit) - ) - res = await db.execute(query) - convs = res.scalars().all() - - # Query emotion analysis for all messages in convs - message_ids = [m.id for c in convs for m in c.messages] - emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} - if message_ids: - emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) - emo_res = await db.execute(emo_query) - for emo in emo_res.scalars().all(): - emotion_map[emo.message_id] = emo - - # Query today's diary entry for cached emotion summary - diary_query = select(DiaryEntry).where(DiaryEntry.user_id == user.id, DiaryEntry.entry_date == today_wib) - res_diary = await db.execute(diary_query) - today_diary = res_diary.scalar_one_or_none() - - mood_tag = today_diary.mood_tag if today_diary and today_diary.mood_tag else "Tenang & Nyaman 🌿" - ai_insight = today_diary.ai_insight if today_diary and today_diary.ai_insight else "Pengguna merasa didengarkan dan tenang setelah berdialog bersama LUNA." - - items = [] - for c in convs: - msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] - if not msgs: - continue + return await service.get_today_conversations(user.id, page=page, limit=limit) - time_formatted = _format_time_local(c.started_at) - c_emotions = [emotion_map[m.id].emotions for m in msgs if m.id in emotion_map and emotion_map[m.id].emotions] - if c_emotions: - breakdown = MLEmotionDetectorService.aggregate_session_emotions(c_emotions) - else: - breakdown = [ - {"label": "Ketenangan & Kedamaian", "emoji": "😌", "percent": 0.70, "color": "#4ECDC4"}, - {"label": "Bahagia & Puas", "emoji": "😃", "percent": 0.20, "color": "#FFE6A7"}, - {"label": "Tingkat Stres", "emoji": "😟", "percent": 0.10, "color": "#FF8B94"}, - ] - - analysis = { - "dominant_emotion": mood_tag, - "calm_score": "85%", - "stress_level": "Rendah", - "empathy_level": "Sangat Tinggi", - "ai_insight": ai_insight, - "emotions_breakdown": breakdown, - } - - transcript_items = [] - for m in msgs: - emo_tag, emo_emoji = _get_message_emotion(m, emotion_map) - transcript_items.append({ - "role": m.role, - "content": m.content, - "time": _format_time_local(m.created_at), - "emotionTag": emo_tag if m.role == "user" else None, - "emotionEmoji": emo_emoji if m.role == "user" else None, - }) - - items.append({ - "id": str(c.id), - "title": c.title or "Sesi Panggilan Suara LUNA", - "is_title_generating": "$skeleton" in (c.title or ""), - "date": f"Hari ini, {time_formatted}", - "duration": _format_duration_str(c.started_at, c.last_message_at), - "moodTag": mood_tag, - "emotion_analysis": analysis, - "transcript": transcript_items, - }) - - return { - "items": items, - "total": total_items, - "page": page, - "limit": limit, - "has_more": (offset + len(items)) < total_items, - } - - -@router.get("/today/messages") +@router.get("/today/messages", response_model=TodayMessagesResponse) async def get_today_conversation_messages( page: int = Query(1, ge=1, description="Page number for pagination"), limit: int = Query(10, ge=1, le=100, description="Items per page"), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: + user: User = Depends(get_current_user), + service: ConversationService = Depends(get_conversation_service), +) -> TodayMessagesResponse: """Retrieve all messages from today's conversations ordered chronologically (Oldest First) with pagination.""" - today_wib = get_wib_today() - start_utc, end_utc = get_wib_day_range_utc(today_wib) - - # Query all today's conversations ordered chronologically (Oldest First) - conv_query = ( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where( - Conversation.user_id == user.id, - Conversation.started_at >= start_utc, - Conversation.started_at <= end_utc, - ) - .order_by(Conversation.started_at.asc()) - ) - conv_res = await db.execute(conv_query) - convs = conv_res.scalars().all() + return await service.get_today_messages(user.id, page=page, limit=limit) - # Query emotion analysis for all messages in convs - message_ids = [m.id for c in convs for m in c.messages] - emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} - if message_ids: - emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) - emo_res = await db.execute(emo_query) - for emo in emo_res.scalars().all(): - emotion_map[emo.message_id] = emo - all_msgs = [] - for c in convs: - sorted_msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] - sess_title = (c.title or "Sesi Panggilan Suara LUNA").replace("$skeleton", "").strip() - for m in sorted_msgs: - if m.role in ("user", "assistant") and m.content and m.content.strip(): - is_user = m.role == "user" - emo_tag, emo_emoji = _get_message_emotion(m, emotion_map) - all_msgs.append({ - "id": str(m.id), - "role": m.role, - "isUser": is_user, - "content": m.content, - "text": m.content, - "time": _format_time_local(m.created_at or c.started_at), - "sessionTitle": sess_title, - "emotionTag": emo_tag if is_user else None, - "emotionEmoji": emo_emoji if is_user else None, - }) +@router.get("/{conversation_id}", response_model=ConversationDetailResponse) +async def get_conversation_by_id( + conversation_id: str, + service: ConversationService = Depends(get_conversation_service), +) -> ConversationDetailResponse: + """Retrieve details and messages of a specific conversation session.""" + return await service.get_conversation_by_id(conversation_id) - total_items = len(all_msgs) - total_pages = (total_items + limit - 1) // limit if total_items > 0 else 1 - offset = (page - 1) * limit - items = all_msgs[offset : offset + limit] - return { - "items": items, - "total_items": total_items, - "page": page, - "limit": limit, - "total_pages": total_pages, - } - - -@router.get("/{conversation_id}") -async def get_conversation_by_id(conversation_id: str, db: AsyncSession = Depends(get_db_session)) -> dict[str, Any]: - try: - c_uuid = uuid.UUID(conversation_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid conversation ID") - - query = ( - select(Conversation) - .options( - selectinload(Conversation.messages), - selectinload(Conversation.summaries), - ) - .where(Conversation.id == c_uuid) - ) - res = await db.execute(query) - conv = res.scalar_one_or_none() - - if not conv: - raise HTTPException(status_code=404, detail="Conversation not found") - - message_ids = [m.id for m in conv.messages] if conv.messages else [] - emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} - if message_ids: - emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) - emo_res = await db.execute(emo_query) - for emo in emo_res.scalars().all(): - emotion_map[emo.message_id] = emo - - return _format_conversation(conv, emotion_map) - - -@router.post("/{conversation_id}/messages") +@router.post("/{conversation_id}/messages", response_model=SentMessageResponse) async def send_message( - conversation_id: str, payload: SendMessageRequest, db: AsyncSession = Depends(get_db_session) -) -> dict[str, Any]: - try: - c_uuid = uuid.UUID(conversation_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid conversation ID") - - query = ( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where(Conversation.id == c_uuid) - ) - res = await db.execute(query) - conv = res.scalar_one_or_none() - - if not conv: - raise HTTPException(status_code=404, detail="Conversation not found") - - next_seq = len(conv.messages) + 1 - user_msg = Message( - conversation_id=conv.id, - role="user", - content=payload.content, - message_type=MessageType.TEXT, - sequence_number=next_seq, - ) - db.add(user_msg) - await db.commit() - await db.refresh(user_msg) - - if payload.content and payload.content.strip(): - try: - analysis_result = await MLEmotionDetectorService.predict_message_emotion(payload.content) - await MLEmotionDetectorService.save_emotion_to_db(user_msg.id, analysis_result, db) - logger.info(f"🧠 [CHAT EMOTION PERSISTED]: Saved emotion for message {user_msg.id}") - except Exception as emo_err: - logger.warning(f"⚠️ [CHAT EMOTION EXCEPTION]: Failed to save emotion analysis: {emo_err}") - - return { - "id": str(user_msg.id), - "sender": "user", - "text": user_msg.content, - "time": "Sekarang", - "isAudio": False, - } + conversation_id: str, + payload: SendMessageRequest, + service: ConversationService = Depends(get_conversation_service), +) -> SentMessageResponse: + """Send and persist a new message within a conversation.""" + return await service.send_message(conversation_id, payload) @router.get("/{conversation_id}/summary/stream") async def stream_conversation_summary( conversation_id: uuid.UUID, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -): + user: User = Depends(get_current_user), + service: SummaryStreamService = Depends(get_summary_stream_service), +) -> StreamingResponse: """Stream 2-3 sentence empathetic counseling summary token-by-token using SSE and persist upon completion.""" - query = ( - select(Conversation) - .options( - selectinload(Conversation.messages), - selectinload(Conversation.summaries), - ) - .where(Conversation.id == conversation_id, Conversation.user_id == user.id) - ) - res = await db.execute(query) - conv = res.scalar_one_or_none() - if not conv: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Percakapan tidak ditemukan", - ) - - # 1. If already persisted in DB, yield cached summary immediately - if conv.summaries and conv.summaries[-1].summary: - existing_summary = conv.summaries[-1].summary - - async def cached_stream(): - yield f"data: {json.dumps({'token': existing_summary})}\n\n" - yield f"data: {json.dumps({'done': True, 'summary': existing_summary})}\n\n" - - return StreamingResponse( - cached_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - # 2. Extract user and assistant messages for transcript - msgs = sorted(conv.messages, key=lambda m: m.created_at) if conv.messages else [] - user_assistant_msgs = [ - m for m in msgs if m.role in ("user", "assistant") and m.content and m.content.strip() - ] - - CRISIS_KEYWORDS = ( - "bunuh diri", "capek hidup", "mati saja", "ingin mati", "putus asa", - "menyakiti diri", "gantung diri", "akhiri hidup", "tak sanggup lagi", - "tidak sanggup hidup" - ) - transcript_text = "\n".join([f"{m.role.upper()}: {m.content}" for m in user_assistant_msgs]) - full_lower = transcript_text.lower() - has_crisis = any(kw in full_lower for kw in CRISIS_KEYWORDS) - - if not user_assistant_msgs: - fallback_summary = "Sesi percakapan curhat bersama LUNA berjalan dengan tenang dan aman." - - async def empty_stream(): - yield f"data: {json.dumps({'token': fallback_summary})}\n\n" - yield f"data: {json.dumps({'done': True, 'summary': fallback_summary})}\n\n" - - return StreamingResponse( - empty_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - system_prompt = LLMMessage( - role="system", - content=( - "Anda adalah konselor psikologis profesional LUNA AI. " - "Tugas Anda adalah membuat ringkasan percakapan konseling yang sangat ringkas, padat, dan menenangkan (tepat 2-3 kalimat Bahasa Indonesia).\n" - "Pedoman wajib:\n" - "1. Fokus pada esensi perasaan yang dicurahkan pengguna dan pendampingan reflektif dari LUNA.\n" - "2. Gunakan nada bicara hangat, empatik, tenang, dan suportif.\n" - "3. DILARANG KERAS mengutip mentah kata-kata pengguna dalam tanda kutip.\n" - "4. Jika terdeteksi kondisi krisis berat, berikan narasi suportif yang menekankan ruang aman dan pentingnya pertolongan profesional.\n" - "5. Hasilkan HANYA teks ringkasan langsung tanpa judul, tanpa pembuka/penutup, dan tanpa format markdown." - ), - ) - user_prompt = LLMMessage( - role="user", - content=f"Berikut transkrip percakapan suara:\n\n{transcript_text}\n\nBuat ringkasan konseling 2-3 kalimat:", - ) - - async def generate_stream(): - full_tokens: list[str] = [] - try: - llm_provider = LLMFactory.get_provider() - async for token in llm_provider.stream_response([system_prompt, user_prompt]): - full_tokens.append(token) - yield f"data: {json.dumps({'token': token})}\n\n" - except Exception as e: - logger.error(f"⚠️ [SUMMARY STREAM LLM EXCEPTION]: {e}") - fallback_text = ( - "Pengguna meluangkan waktu untuk mengekspresikan beban emosional yang berat dalam sesi ini. " - "LUNA hadir mendampingi secara suportif, memberikan ruang aman untuk bercerita, " - "dan menegaskan bahwa setiap perasaan berharga serta bantuan selalu tersedia." - if has_crisis - else "Pengguna berbagi refleksi harian dan tantangan perasaan secara terbuka. " - "LUNA memberikan tanggapan yang menenangkan serta ruang aman untuk meredakan ketegangan." - ) - if not full_tokens: - yield f"data: {json.dumps({'token': fallback_text})}\n\n" - full_tokens.append(fallback_text) - - full_summary = "".join(full_tokens).strip() - # Clean any stray wrapping quotes - if full_summary.startswith('"') and full_summary.endswith('"') and len(full_summary) > 2: - full_summary = full_summary[1:-1].strip() - - # Persist summary to database so subsequent calls are instantaneous - if full_summary: - try: - async with AsyncSessionLocal() as db_stream: - new_summary = ConversationSummary( - conversation_id=conversation_id, - summary=full_summary, - ) - db_stream.add(new_summary) - await db_stream.commit() - logger.info(f"✅ [SUMMARY STREAM PERSISTED]: Conversation {conversation_id}") - except Exception as db_err: - logger.error(f"⚠️ [FAILED TO PERSIST STREAM SUMMARY]: {db_err}") - - dominant_emotion = "Krisis / Sangat Tertekan" if has_crisis else None - dominant_emoji = "🆘" if has_crisis else None - - yield f"data: {json.dumps({'done': True, 'summary': full_summary, 'dominant_emotion': dominant_emotion, 'dominant_emoji': dominant_emoji})}\n\n" - - return StreamingResponse( - generate_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - + return await service.stream_summary(conversation_id, user.id) diff --git a/apps/backend/api/app/api/routes/dass.py b/apps/backend/api/app/api/routes/dass.py index c0c90ad..f097db1 100644 --- a/apps/backend/api/app/api/routes/dass.py +++ b/apps/backend/api/app/api/routes/dass.py @@ -1,137 +1,52 @@ import logging import uuid -from typing import Any -from fastapi import APIRouter, Depends, Header, HTTPException, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from fastapi import APIRouter, Depends -from app.core.security import decode_access_token -from app.core.tz import get_wib_day_range_utc, get_wib_today -from ai.services.dass_extraction_service import DASSExtractionService -from app.db.session import get_db_session -from app.models.conversation import Conversation, Message +from app.api.dependencies import get_current_user, get_dass_service from app.models.user import User from app.schemas.dass import DASSAssessmentResponse, DASSAssessmentUpdate -from app.services.dass_service import DASSService +from app.services.dass import DASSService logger = logging.getLogger(__name__) router = APIRouter(prefix="/dass", tags=["DASS-21"]) -async def _get_current_user( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> User: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - @router.get("/today", response_model=DASSAssessmentResponse) async def get_today_dass( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> Any: - """Mengambil status dan butir asesmen DASS-21 hari ini untuk pengguna.""" - today_wib = get_wib_today() - return await DASSService.get_today_assessment(user.id, today_wib, db) + user: User = Depends(get_current_user), + service: DASSService = Depends(get_dass_service), +) -> DASSAssessmentResponse: + """Retrieve today's DASS-21 assessment status and items for the authenticated user.""" + return await service.get_today_assessment(user.id) @router.put("/today", response_model=DASSAssessmentResponse) async def update_today_dass( payload: DASSAssessmentUpdate, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> Any: - """Menerima koreksi skor butir DASS-21 dari pengguna dan melakukan rekalkulasi skor.""" - today_wib = get_wib_today() - return await DASSService.update_user_assessment(user.id, today_wib, payload.items, db) + user: User = Depends(get_current_user), + service: DASSService = Depends(get_dass_service), +) -> DASSAssessmentResponse: + """Receive user item score corrections and recalculate subscale severity scores.""" + return await service.update_user_assessment(user.id, payload.items) @router.put("/{assessment_id}", response_model=DASSAssessmentResponse) async def update_dass_by_id( assessment_id: uuid.UUID, payload: DASSAssessmentUpdate, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> Any: - """Menerima koreksi skor butir DASS-21 berdasarkan ID asesmen spesifik.""" - today_wib = get_wib_today() - return await DASSService.update_user_assessment(user.id, today_wib, payload.items, db) + user: User = Depends(get_current_user), + service: DASSService = Depends(get_dass_service), +) -> DASSAssessmentResponse: + """Receive user score corrections for a specific assessment ID.""" + return await service.update_user_assessment(user.id, payload.items, assessment_id=assessment_id) @router.post("/extract-today", response_model=DASSAssessmentResponse) async def extract_today_dass( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> Any: - """Memicu analisis ekstraksi AI 21 butir DASS-21 dari seluruh transkrip percakapan hari ini.""" - today_wib = get_wib_today() - start_utc, end_utc = get_wib_day_range_utc(today_wib) - - # Ambil percakapan hari ini - conv_stmt = ( - select(Conversation) - .where( - Conversation.user_id == user.id, - Conversation.created_at >= start_utc, - Conversation.created_at <= end_utc, - ) - .options(selectinload(Conversation.messages)) - ) - conv_res = await db.execute(conv_stmt) - conversations = conv_res.scalars().all() - - formatted_msgs = [] - latest_conv_id = None - for c in conversations: - latest_conv_id = c.id - for m in sorted(c.messages, key=lambda x: x.created_at): - if m.content and m.content.strip(): - formatted_msgs.append(f"{m.role.upper()}: {m.content}") - - transcript = "\n".join(formatted_msgs) - extractor = DASSExtractionService() - extracted_items = await extractor.extract_from_transcript(transcript) - - saved_record = await DASSService.save_or_update_extracted( - user_id=user.id, - assessed_date=today_wib, - extracted_items=extracted_items, - conversation_id=latest_conv_id, - db=db, - ) - return DASSAssessmentResponse.model_validate(saved_record) + user: User = Depends(get_current_user), + service: DASSService = Depends(get_dass_service), +) -> DASSAssessmentResponse: + """Trigger AI extraction analysis of 21 DASS items from all conversation transcripts today.""" + return await service.extract_today_assessment(user.id) diff --git a/apps/backend/api/app/api/routes/diaries.py b/apps/backend/api/app/api/routes/diaries.py index c71a32d..a1e3bbe 100644 --- a/apps/backend/api/app/api/routes/diaries.py +++ b/apps/backend/api/app/api/routes/diaries.py @@ -1,406 +1,74 @@ -import uuid -from datetime import date import logging -from typing import Any -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status -from pydantic import BaseModel -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from fastapi import APIRouter, Depends, Query -from app.core.security import decode_access_token -from app.core.tz import get_wib_day_range_utc, get_wib_today, to_wib -from app.db.session import get_db_session -from app.models.conversation import Conversation -from app.models.diary import DiaryEntry +from app.api.dependencies import get_current_user, get_diary_service from app.models.user import User -from app.services.diary_generator import DiaryGeneratorService +from app.schemas.diary import ( + CreateDiaryRequest, + DeleteDiaryResponse, + DiaryEntryResponse, +) +from app.services.diary import DiaryService logger = logging.getLogger(__name__) router = APIRouter(prefix="/diaries", tags=["Diaries"]) -class CreateDiaryRequest(BaseModel): - content: str - mood_tag: str | None = None - - -async def _get_current_user( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> User: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - -def _format_diary_entry(d: DiaryEntry | None) -> dict[str, Any]: - if not d: - return {} - raw_events = d.important_events if isinstance(d.important_events, list) else [] - events = raw_events if raw_events else ["Sesi refleksi harian tercatat dalam sistem LUNA."] - - scores = d.mental_health_scores or {} - dep = float(scores.get("depression", 0.25)) - anx = float(scores.get("anxiety", 0.45)) - strs = float(scores.get("stress", 0.15)) - - raw_breakdown = [ - {"name": "fear", "label": "Takut / Gelisah", "emoji": "😨", "percent": max(0.05, anx), "color": "#6C63FF"}, - {"name": "sadness", "label": "Sedih / Haru", "emoji": "😔", "percent": max(0.05, dep), "color": "#8B93FF"}, - {"name": "netral", "label": "Netral", "emoji": "😐", "percent": 0.15, "color": "#A7E6FF"}, - {"name": "stress", "label": "Stres / Tertekan", "emoji": "💥", "percent": max(0.05, strs), "color": "#FF8A80"}, - {"name": "happy", "label": "Bahagia", "emoji": "😃", "percent": 0.10, "color": "#FFE6A7"}, - {"name": "surprise", "label": "Terkejut", "emoji": "😲", "percent": 0.03, "color": "#C3B8FF"}, - {"name": "anger", "label": "Marah", "emoji": "😡", "percent": 0.02, "color": "#FFB6C1"}, - ] - tot = sum(item["percent"] for item in raw_breakdown) - emotions_breakdown = [ - { - **item, - "percent": round(item["percent"] / tot, 2), - } - for item in raw_breakdown - ] - - return { - "id": str(d.id), - "title": d.title or "Refleksi Harian LUNA", - "date": d.entry_date.strftime("%d %B %Y") if isinstance(d.entry_date, date) else str(d.entry_date), - "sessionCount": 0, - "lastSessionTime": "-", - "moodTag": d.mood_tag or "Netral", - "moodEmoji": d.mood_emoji or "😌", - "summary": d.summary or "Catatan harian perkembangan emosional bersama LUNA.", - "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.", - } if ("Darurat" in (d.mood_tag or "") or "Stres" in (d.mood_tag or "") or "Cemas" in (d.mood_tag or "") or "Sedih" in (d.mood_tag or "")) else None, - "aiInsight": d.ai_insight or "Analisis AI menunjukkan kondisi stabil.", - "importantEvents": events, - "emotionalReflection": d.emotional_reflection or "Merasa tenang setelah refleksi.", - "mentalHealthScores": d.mental_health_scores or {}, - "emotionsBreakdown": emotions_breakdown, - "sessions": [], - } - - -def _calculate_session_emotions( - transcripts: list[dict[str, Any]], - session_title: str, - default_breakdown: list[dict[str, Any]], -) -> list[dict[str, Any]]: - combined_text = (session_title + " " + " ".join(t["text"] for t in transcripts if t.get("isUser"))).lower() - - anx_keywords = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup"] - dep_keywords = ["bunuh diri", "depresi", "sedih", "sendiri", "kehilangan", "hampa", "nangis", "menangis", "terpuruk"] - str_keywords = ["stres", "capek", "lelah", "beban", "berat", "penat", "tugas", "kerja"] - pos_keywords = ["lega", "senang", "terima kasih", "enakan", "tenang", "nyaman", "tidur", "santai", "baik"] - - anx_count = sum(1 for kw in anx_keywords if kw in combined_text) - dep_count = sum(1 for kw in dep_keywords if kw in combined_text) - str_count = sum(1 for kw in str_keywords if kw in combined_text) - pos_count = sum(1 for kw in pos_keywords if kw in combined_text) - - if anx_count == 0 and dep_count == 0 and str_count == 0 and pos_count == 0: - return default_breakdown - - raw = [ - {"name": "fear", "label": "Takut / Gelisah", "emoji": "😨", "percent": max(0.04, anx_count * 0.25), "color": "#6C63FF"}, - {"name": "sadness", "label": "Sedih / Haru", "emoji": "😔", "percent": max(0.04, dep_count * 0.25), "color": "#8B93FF"}, - {"name": "netral", "label": "Netral", "emoji": "😐", "percent": 0.08, "color": "#A7E6FF"}, - {"name": "stress", "label": "Stres / Tertekan", "emoji": "💥", "percent": max(0.04, str_count * 0.20), "color": "#FF8A80"}, - {"name": "happy", "label": "Bahagia", "emoji": "😃", "percent": max(0.03, pos_count * 0.15), "color": "#FFE6A7"}, - {"name": "surprise", "label": "Terkejut", "emoji": "😲", "percent": 0.02, "color": "#C3B8FF"}, - {"name": "anger", "label": "Marah", "emoji": "😡", "percent": 0.01, "color": "#FFB6C1"}, - ] - tot = sum(item["percent"] for item in raw) - return [ - {**item, "percent": round(item["percent"] / tot, 2)} - for item in raw - ] - - -def _analyze_message_emotion(text: str, is_user: bool) -> tuple[str, str]: - """Dynamically determine emotion tag and emoji from individual message content.""" - if not is_user: - return "empathy", "💙" - - t_low = text.lower() - # 1. Cemas / Takut (Fear / Anxiety) - anx_words = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup", "takutnya", "bingung"] - # 2. Sedih / Terpuruk (Sadness / Depression) - dep_words = ["sedih", "nangis", "menangis", "hampa", "sendiri", "kehilangan", "terpuruk", "putus asa", "kecewa", "patah hati", "bunuh diri"] - # 3. Stres / Beban / Lelah (Stress / Exhaustion) - str_words = ["stres", "capek", "lelah", "beban", "berat", "penat", "pusing", "mumet", "tekanan", "tugas", "deadline", "kerjaan"] - # 4. Marah / Kesal (Anger) - ang_words = ["marah", "kesal", "jengkel", "benci", "emosi", "sebal", "kesel"] - # 5. Lega / Tenang / Positif (Relief / Joy) - pos_words = ["lega", "senang", "bahagia", "terima kasih", "makasih", "enakan", "tenang", "nyaman", "santai", "alhamdulillah", "syukurlah", "baik"] - - if any(w in t_low for w in anx_words): - return "fear (78%)", "😨" - if any(w in t_low for w in dep_words): - return "sadness (82%)", "😔" - if any(w in t_low for w in str_words): - return "stress (75%)", "💥" - if any(w in t_low for w in ang_words): - return "anger (70%)", "😡" - if any(w in t_low for w in pos_words): - return "calm (85%)", "😌" - - return "netral (60%)", "😐" - - -async def _attach_sessions(formatted: dict[str, Any], entry: DiaryEntry, db: AsyncSession) -> dict[str, Any]: - """Populate 'sessions' with real conversation transcripts for the diary's date.""" - if not isinstance(entry.entry_date, date): - return formatted - - start_utc, end_utc = get_wib_day_range_utc(entry.entry_date) - conv_query = ( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where( - Conversation.user_id == entry.user_id, - Conversation.started_at >= start_utc, - Conversation.started_at <= end_utc, - ) - .order_by(Conversation.started_at.asc()) - ) - conv_res = await db.execute(conv_query) - convs = conv_res.scalars().all() - - sessions_data = [] - for c in convs: - sorted_msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] - sess_transcripts = [] - for m in sorted_msgs: - if m.role in ("user", "assistant") and m.content: - msg_t = to_wib(m.created_at) - emo_tag, emo_emoji = _analyze_message_emotion(m.content, m.role == "user") - sess_transcripts.append({ - "isUser": m.role == "user", - "time": msg_t.strftime("%H:%M") if msg_t else "09:00", - "text": m.content, - "emotionTag": emo_tag, - "emotionEmoji": emo_emoji, - }) - - # Skip empty sessions without user/assistant dialogue - if not sess_transcripts: - continue - - c_t = to_wib(c.started_at) - time_str = c_t.strftime("%H:%M") if c_t else "09:00" - title = (c.title or "").replace("$skeleton", "").strip() - if not title: - title = f"Sesi #{len(sessions_data) + 1} Percakapan Suara" - - sess_emotions = _calculate_session_emotions( - sess_transcripts, - title, - formatted.get("emotionsBreakdown") or [], - ) - - sessions_data.append({ - "id": str(c.id), - "title": title, - "time": time_str, - "moodTag": entry.mood_tag or "Netral", - "moodEmoji": entry.mood_emoji or "😌", - "emotionsBreakdown": sess_emotions, - "transcripts": sess_transcripts, - }) - - formatted["sessions"] = sessions_data - formatted["sessionCount"] = len(sessions_data) - if sessions_data: - formatted["lastSessionTime"] = sessions_data[-1]["time"] - else: - formatted["lastSessionTime"] = "-" - return formatted - - -@router.get("") +@router.get("", response_model=list[DiaryEntryResponse]) async def get_diaries( mood: str | None = Query(None), search: str | None = Query(None), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> list[dict[str, Any]]: - query = select(DiaryEntry).where(DiaryEntry.user_id == user.id).order_by(DiaryEntry.entry_date.desc()) - res = await db.execute(query) - entries = res.scalars().all() + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> list[DiaryEntryResponse]: + """Retrieve all diary entries for authenticated user with optional mood and search filters.""" + return await service.get_diaries(user.id, mood=mood, search=search) - if not entries: - return [] - formatted = [] - for e in entries: - if e is not None: - f = _format_diary_entry(e) - f = await _attach_sessions(f, e, db) - formatted.append(f) - - if mood and mood != "Semua": - clean_mood = mood.lower() - formatted = [f for f in formatted if clean_mood in f["moodTag"].lower()] - - if search: - clean_q = search.lower() - formatted = [ - f for f in formatted - if clean_q in f["title"].lower() or clean_q in f["summary"].lower() - ] - - return formatted - - -@router.get("/today") +@router.get("/today", response_model=DiaryEntryResponse) async def get_today_diary( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - today = get_wib_today() - query = select(DiaryEntry).where(DiaryEntry.user_id == user.id, DiaryEntry.entry_date == today) - res = await db.execute(query) - entry = res.scalar_one_or_none() - - if not entry: - entry = await DiaryGeneratorService.generate_today_diary(user.id, db) + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> DiaryEntryResponse: + """Retrieve today's diary entry, auto-generating via LLM if not yet present.""" + return await service.get_today_diary(user.id) - if not entry: - return {"id": None, "message": "Belum ada jurnal untuk hari ini."} - formatted = _format_diary_entry(entry) - formatted = await _attach_sessions(formatted, entry, db) - return formatted - - -@router.get("/{diary_id}") +@router.get("/{diary_id}", response_model=DiaryEntryResponse) async def get_diary_by_id( diary_id: str, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - try: - d_uuid = uuid.UUID(diary_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid diary ID") - - query = select(DiaryEntry).where( - DiaryEntry.id == d_uuid, - DiaryEntry.user_id == user.id, - ) - res = await db.execute(query) - entry = res.scalar_one_or_none() - - if not entry: - raise HTTPException(status_code=404, detail="Diary entry not found") + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> DiaryEntryResponse: + """Retrieve a single diary entry by ID with ownership verification.""" + return await service.get_diary_by_id(user.id, diary_id) - formatted = _format_diary_entry(entry) - formatted = await _attach_sessions(formatted, entry, db) - return formatted - -@router.post("/generate") +@router.post("/generate", response_model=DiaryEntryResponse) async def generate_today_diary( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - entry = await DiaryGeneratorService.generate_today_diary(user.id, db) - return _format_diary_entry(entry) + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> DiaryEntryResponse: + """Force re-synthesis of today's diary from all conversation transcripts.""" + return await service.generate_today_diary(user.id) -@router.post("") +@router.post("", response_model=DiaryEntryResponse) async def create_diary( payload: CreateDiaryRequest, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - today = get_wib_today() - - query = select(DiaryEntry).where(DiaryEntry.user_id == user.id, DiaryEntry.entry_date == today) - res = await db.execute(query) - entry = res.scalar_one_or_none() - - if entry: - entry.summary += f"\n- {payload.content}" - entry.content += f"\n- {payload.content}" - if payload.mood_tag: - entry.mood_tag = payload.mood_tag - else: - entry = DiaryEntry( - user_id=user.id, - entry_date=today, - title="Refleksi Catatan Baru", - summary=payload.content, - content=payload.content, - mood_tag=payload.mood_tag or "Netral", - mood_emoji="😌", - ai_insight="Catatan refleksi berhasil disimpan.", - emotional_reflection="Merasa lega setelah menulis catatan.", - important_events=[f"[Sesi #1] {payload.content[:50]}"], - ) - db.add(entry) - - await db.commit() - await db.refresh(entry) + user: User = Depends(get_current_user), + service: DiaryService = Depends(get_diary_service), +) -> DiaryEntryResponse: + """Create or append today's custom diary entry reflection.""" + return await service.create_diary(user.id, payload) - return _format_diary_entry(entry) - - -@router.delete("/{diary_id}") -async def delete_diary(diary_id: str, db: AsyncSession = Depends(get_db_session)) -> dict[str, str]: - try: - d_uuid = uuid.UUID(diary_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid diary ID") - - query = select(DiaryEntry).where(DiaryEntry.id == d_uuid) - res = await db.execute(query) - entry = res.scalar_one_or_none() - - if not entry: - raise HTTPException(status_code=404, detail="Diary entry not found") - - await db.delete(entry) - await db.commit() - return {"message": "Diary entry deleted successfully"} +@router.delete("/{diary_id}", response_model=DeleteDiaryResponse) +async def delete_diary( + diary_id: str, + service: DiaryService = Depends(get_diary_service), +) -> DeleteDiaryResponse: + """Delete a diary entry by ID.""" + return await service.delete_diary(diary_id) diff --git a/apps/backend/api/app/api/routes/health.py b/apps/backend/api/app/api/routes/health.py index 225c9bb..2b6591a 100644 --- a/apps/backend/api/app/api/routes/health.py +++ b/apps/backend/api/app/api/routes/health.py @@ -1,6 +1,6 @@ from app.api.dependencies import get_redis_client from app.schemas.health import HealthCheckResponse -from app.services.status_service import SystemStatusService +from app.services.status import SystemStatusService from fastapi import APIRouter, Depends from redis.asyncio import Redis from shared.config import settings diff --git a/apps/backend/api/app/api/routes/recommendations.py b/apps/backend/api/app/api/routes/recommendations.py index 3bc33ae..df23506 100644 --- a/apps/backend/api/app/api/routes/recommendations.py +++ b/apps/backend/api/app/api/routes/recommendations.py @@ -1,458 +1,45 @@ -from datetime import date import logging -from typing import Any -import uuid -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status -from sqlalchemy import desc, func, select -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends, Query -from app.core.security import decode_access_token -from app.core.tz import get_wib_today -from app.db.session import get_db_session -from app.models.coping_activity import CopingActivity, UserActivityCompletion -from app.models.dass import DASSAssessment -from app.models.diary import DiaryEntry -from app.models.enums import SafetyEventStatus -from app.models.recommendation import RecommendationItem -from app.models.safety import SafetyEvent +from app.api.dependencies import get_current_user, get_recommendation_service from app.models.user import User +from app.schemas.recommendation import ( + CompleteActivityResponse, + CopingActivityResponse, + TodayRecommendationResponse, +) +from app.services.recommendation import RecommendationService logger = logging.getLogger(__name__) router = APIRouter(prefix="/recommendations", tags=["Recommendations"]) -async def _get_current_user( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> User: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - -@router.get("/today") +@router.get("/today", response_model=TodayRecommendationResponse) async def get_today_recommendation( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - """Retrieve today's tailored coping activities & condition guidance based on - DASS-21 psychological assessment, Safety Crisis Event, and Diary Mental Health Scores. - """ - today_wib = get_wib_today() - - # 1. Check open Crisis / Suicide Risk - is_crisis = False - crisis_hotline = "Kemenkes 119 ext 8 / LISA 0811-3855-472" - crisis_guidance = ( - "Layanan konseling mandiri AI dibatasi saat terindikasi krisis emosional tinggi. " - "Keselamatanmu adalah hal yang paling berharga. Tolong segera hubungi tenaga profesional atau orang terdekat sekarang juga." - ) - - safety_query = ( - select(SafetyEvent) - .where( - SafetyEvent.user_id == user.id, - SafetyEvent.status == SafetyEventStatus.OPEN, - SafetyEvent.severity.in_(["high", "critical"]), - ) - .order_by(desc(SafetyEvent.detected_at)) - .limit(1) - ) - res_safety = await db.execute(safety_query) - open_safety_event = res_safety.scalars().first() - - # 2. Fetch today's or most recent DASS-21 assessment - dass_query = ( - select(DASSAssessment) - .where(DASSAssessment.user_id == user.id) - .order_by(desc(DASSAssessment.assessed_date)) - .limit(1) - ) - res_dass = await db.execute(dass_query) - latest_dass = res_dass.scalars().first() - - # 3. Fetch today's or recent diary entry - diary_query = ( - select(DiaryEntry) - .where(DiaryEntry.user_id == user.id, DiaryEntry.entry_date == today_wib) - .order_by(desc(DiaryEntry.created_at)) - ) - res_diary = await db.execute(diary_query) - today_diary = res_diary.scalars().first() - - if not today_diary: - recent_query = ( - select(DiaryEntry) - .where(DiaryEntry.user_id == user.id) - .order_by(desc(DiaryEntry.entry_date)) - .limit(1) - ) - res_recent = await db.execute(recent_query) - today_diary = res_recent.scalars().first() - - # Check for suicide / self-harm indication in DASS item 21 - suicide_dass_detected = False - if latest_dass and latest_dass.items: - for it in latest_dass.items: - if it.get("item_id") == 21 and int(it.get("score", 0)) >= 2: - suicide_dass_detected = True - break - - if open_safety_event or suicide_dass_detected: - is_crisis = True - dominant_condition = "crisis" - severity_level = "Krisis / Darurat" - condition_label = "Protokol Krisis & Bantuan Darurat" - condition_reason = ( - "Terdeteksi indikasi krisis emosional atau pemikiran menyakiti diri. " - "Sesuai pedoman klinis WHO (Knowledge K013), prioritas utama saat ini adalah keselamatan dan pendampingan darurat." - ) - else: - # Default general - dominant_condition = "general" - severity_level = "Normal" - condition_label = "Fokus: Relaksasi & Perawatan Diri" - condition_reason = "Menjaga ritme emosi dan ketenangan pikiran harian dengan latihan mandiri sederhana." - - # Prioritas 1: Asesmen DASS-21 - if latest_dass and (latest_dass.stress_score > 0 or latest_dass.anxiety_score > 0 or latest_dass.depression_score > 0): - s_score = latest_dass.stress_score - a_score = latest_dass.anxiety_score - d_score = latest_dass.depression_score - - if a_score >= s_score and a_score >= d_score and a_score >= 8: - dominant_condition = "anxiety" - sev = latest_dass.anxiety_severity - if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: - severity_level = "Berat" - condition_label = "Kecemasan Berat: Grounding Somatis" - condition_reason = ( - "Terdeteksi kecemasan tinggi atau overthinking intens. " - "Teknik Grounding somatis menstabilkan respon panik (Knowledge K002)." - ) - elif sev in ["Moderate", "Sedang"]: - severity_level = "Sedang" - condition_label = "Kecemasan Sedang: Notice & Name" - condition_reason = ( - "Terdeteksi kecemasan sedang & overthinking. " - "Metode 'Notice and Name' terbukti membantu melepaskan diri dari pikiran menjebak (Knowledge K003)." - ) - else: - severity_level = "Ringan" - condition_label = "Kecemasan Ringan: Stabilisasi Napas" - condition_reason = ( - "Terindikasi ketegangan cemas ringan. " - "Latihan pernapasan lambat teratur membantu memperlambat respon fisiologis cemas (Knowledge K009)." - ) - - elif s_score >= d_score and s_score >= 15: - dominant_condition = "stress" - sev = latest_dass.stress_severity - if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: - severity_level = "Berat" - condition_label = "Stres Berat: Grounding Badai Emosi" - condition_reason = ( - "Terdeteksi badai emosional dan stres tingkat berat. " - "Teknik Grounding intensif membantu menstabilkan diri dari pikiran yang membebani (Knowledge K002)." - ) - elif sev in ["Moderate", "Sedang"]: - severity_level = "Sedang" - condition_label = "Stres Sedang: PMR & Solusi Bertahap" - condition_reason = ( - "Terdeteksi stres tingkat sedang. " - "Relaksasi otot progresif (PMR) dan metode Stop-Think-Go meredakan ketegangan (Knowledge K002 & K011)." - ) - else: - severity_level = "Ringan" - condition_label = "Stres Ringan: Pernapasan 4-7-8" - condition_reason = ( - "Terdeteksi beban stres ringan. " - "Latihan napas 4-7-8 dan jeda digital membantu menenangkan pikiran (Knowledge K001)." - ) - - elif d_score >= 10: - dominant_condition = "depression" - sev = latest_dass.depression_severity - if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: - severity_level = "Berat" - condition_label = "Suasana Hati Sangat Rendah: Langkah Mikro" - condition_reason = ( - "Terindikasi keputusasaan atau devaluasi diri berat. " - "Fokuslah pada 1 langkah terkecil dan bicarakan dengan orang terdekat (Knowledge K005 & K010)." - ) - elif sev in ["Moderate", "Sedang"]: - severity_level = "Sedang" - condition_label = "Depresi Sedang: Aktivasi Perilaku" - condition_reason = ( - "Terindikasi kelelahan emosional dan anhedonia. " - "Aktivasi perilaku metode tangga 'Changing My Actions' memutus siklus murung (Knowledge K010)." - ) - else: - severity_level = "Ringan" - condition_label = "Suasana Hati Rendah: Refleksi Syukur" - condition_reason = ( - "Terindikasi suasana hati murung ringan. " - "Melatih rasa syukur dan pengenalan emosi membantu mengimbangi bias negatif (Knowledge K005 & K008)." - ) - - # Prioritas 2: Jika DASS belum terisi, periksa Diary - elif today_diary and today_diary.mental_health_scores: - scores = today_diary.mental_health_scores - stress = float(scores.get("stress", 0.0)) - anxiety = float(scores.get("anxiety", 0.0)) - depression = float(scores.get("depression", 0.0)) - - max_score = max(stress, anxiety, depression) - if max_score >= 0.25: - if anxiety >= stress and anxiety >= depression: - dominant_condition = "anxiety" - severity_level = "Sedang" if anxiety >= 0.6 else "Ringan" - condition_label = f"Kecemasan {severity_level}: Relaksasi Napas" - condition_reason = "Terindikasi kecemasan dari dialog terakhir. Latihan pernapasan terbukti menenangkan saraf (Knowledge K009)." - elif stress >= depression: - dominant_condition = "stress" - severity_level = "Sedang" if stress >= 0.6 else "Ringan" - condition_label = f"Stres {severity_level}: Pelepasan Beban" - condition_reason = "Terindikasi beban stres dari dialog terakhir. Teknik relaksasi membantu mengembalikan ketenangan (Knowledge K001)." - else: - dominant_condition = "depression" - severity_level = "Sedang" if depression >= 0.6 else "Ringan" - condition_label = f"Pemulihan Energi {severity_level}: Aktivasi" - condition_reason = "Terindikasi kelelahan emosional. Aktivitas ringan di luar ruangan membantu membangkitkan suasana hati (Knowledge K010)." - - # Query Coping Activities - query_target = ( - select(CopingActivity) - .where( - CopingActivity.is_active.is_(True), - CopingActivity.target_condition == dominant_condition, - ) - .order_by(CopingActivity.created_at.asc()) - ) - res_target = await db.execute(query_target) - target_activities = res_target.scalars().all() - - query_general = ( - select(CopingActivity) - .where( - CopingActivity.is_active.is_(True), - CopingActivity.target_condition != dominant_condition, - ) - .order_by(CopingActivity.created_at.asc()) - ) - res_general = await db.execute(query_general) - general_activities = res_general.scalars().all() + user: User = Depends(get_current_user), + service: RecommendationService = Depends(get_recommendation_service), +) -> TodayRecommendationResponse: + """Retrieve today's tailored coping activities & guidance based on DASS-21, safety crisis, and diary scores.""" + return await service.get_today_recommendation(user.id) - all_sorted_activities = list(target_activities) + list(general_activities) - # Check user completions for today - completion_query = select(UserActivityCompletion.activity_id).where( - UserActivityCompletion.user_id == user.id, - UserActivityCompletion.completed_date == today_wib, - ) - res_comp = await db.execute(completion_query) - completed_ids = set(res_comp.scalars().all()) - - # Pick the primary activity (prefer incomplete matching activity) - primary_activity: CopingActivity | None = None - for act in target_activities: - if act.id not in completed_ids: - primary_activity = act - break - if not primary_activity and target_activities: - primary_activity = target_activities[0] - if not primary_activity and all_sorted_activities: - primary_activity = all_sorted_activities[0] - - def _map_act(act: CopingActivity) -> dict[str, Any]: - return { - "id": str(act.id), - "title": act.title, - "category": act.category, - "targetCondition": act.target_condition, - "duration": act.duration, - "difficulty": act.difficulty, - "level": act.difficulty, - "description": act.description, - "instructions": act.instructions or [], - "rationale": act.rationale or "", - "iconName": act.icon_name or "spa", - "isCompleted": act.id in completed_ids, - } - - all_recommendations_mapped = [_map_act(a) for a in all_sorted_activities] - - primary_mapped = _map_act(primary_activity) if primary_activity else { - "id": "default-1", - "title": "Latihan Pernapasan 4-7-8", - "category": "Mindfulness", - "targetCondition": dominant_condition, - "duration": "5 Menit", - "difficulty": "Pemula", - "level": "Pemula", - "description": "Teknik pernapasan sederhana untuk menenangkan sistem saraf.", - "instructions": [ - "Tarik napas dalam 4 detik.", - "Tahan napas 7 detik.", - "Hembuskan napas perlahan 8 detik.", - ], - "rationale": "Mengaktifkan saraf parasimpatik untuk meredakan ketegangan tubuh.", - "iconName": "spa", - "isCompleted": False, - } - - alternatives_mapped = [ - _map_act(a) - for a in all_sorted_activities - if primary_activity and a.id != primary_activity.id - ] - - return { - "targetCondition": dominant_condition, - "conditionLabel": condition_label, - "conditionReason": condition_reason, - "severityLevel": severity_level, - "isCrisis": is_crisis, - "crisisHotline": crisis_hotline, - "crisisGuidance": crisis_guidance, - "todayActivity": primary_mapped, - "completedTodayCount": len(completed_ids), - "alternatives": alternatives_mapped, - "allRecommendations": all_recommendations_mapped, - } - - -@router.get("") +@router.get("", response_model=list[CopingActivityResponse]) async def get_recommendations( condition: str | None = Query(None, description="Filter by condition: stress, anxiety, depression, general, crisis"), - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> list[dict[str, Any]]: + user: User = Depends(get_current_user), + service: RecommendationService = Depends(get_recommendation_service), +) -> list[CopingActivityResponse]: """Get all available recommendations / coping activities sorted by condition.""" - today_wib = get_wib_today() - - query = select(CopingActivity).where(CopingActivity.is_active.is_(True)) - if condition: - query = query.where(CopingActivity.target_condition == condition.lower().strip()) - query = query.order_by(CopingActivity.created_at.asc()) - - res = await db.execute(query) - coping_items = res.scalars().all() + return await service.get_recommendations(user.id, condition=condition) - comp_query = select(UserActivityCompletion.activity_id).where( - UserActivityCompletion.user_id == user.id, - UserActivityCompletion.completed_date == today_wib, - ) - res_comp = await db.execute(comp_query) - completed_ids = set(res_comp.scalars().all()) - return [ - { - "id": str(r.id), - "title": r.title, - "category": r.category, - "targetCondition": r.target_condition, - "duration": r.duration, - "difficulty": r.difficulty, - "level": r.difficulty, - "description": r.description, - "instructions": r.instructions or [], - "rationale": r.rationale or "", - "iconName": r.icon_name or "spa", - "isCompleted": r.id in completed_ids, - } - for r in coping_items - ] - - -@router.post("/{recommendation_id}/complete") +@router.post("/{recommendation_id}/complete", response_model=CompleteActivityResponse) async def mark_recommendation_completed( recommendation_id: str, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: + user: User = Depends(get_current_user), + service: RecommendationService = Depends(get_recommendation_service), +) -> CompleteActivityResponse: """Mark a recommendation or coping activity as completed for today.""" - try: - r_uuid = uuid.UUID(recommendation_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid recommendation ID") - - today_wib = get_wib_today() - - # 1. Check if recommendation_id matches CopingActivity - query_act = select(CopingActivity).where(CopingActivity.id == r_uuid) - res_act = await db.execute(query_act) - activity = res_act.scalar_one_or_none() - - if activity: - # Check if already completed today - check_q = select(UserActivityCompletion).where( - UserActivityCompletion.user_id == user.id, - UserActivityCompletion.activity_id == activity.id, - UserActivityCompletion.completed_date == today_wib, - ) - res_check = await db.execute(check_q) - existing = res_check.scalar_one_or_none() - - if not existing: - comp = UserActivityCompletion( - user_id=user.id, - activity_id=activity.id, - completed_date=today_wib, - ) - db.add(comp) - await db.commit() - - return { - "message": "Activity marked as completed for today", - "activityId": str(activity.id), - "completedDate": str(today_wib), - "isCompleted": True, - } - - # 2. Check legacy RecommendationItem - query_legacy = select(RecommendationItem).where(RecommendationItem.id == r_uuid) - res_legacy = await db.execute(query_legacy) - legacy_item = res_legacy.scalar_one_or_none() - - if legacy_item: - legacy_item.is_completed = True - await db.commit() - return { - "message": "Legacy recommendation marked as completed", - "activityId": str(legacy_item.id), - "isCompleted": True, - } - - raise HTTPException(status_code=404, detail="Activity or recommendation item not found") + return await service.mark_completed(user.id, recommendation_id) diff --git a/apps/backend/api/app/api/routes/user.py b/apps/backend/api/app/api/routes/user.py index 6aeb62f..4f06650 100644 --- a/apps/backend/api/app/api/routes/user.py +++ b/apps/backend/api/app/api/routes/user.py @@ -1,204 +1,57 @@ import logging -from typing import Any -import uuid -from fastapi import APIRouter, Depends, Header, HTTPException, status -from pydantic import BaseModel -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Depends -from app.core.security import decode_access_token -from app.db.session import get_db_session -from app.models.user import EmergencyContact, User +from app.api.dependencies import get_current_user, get_user_service +from app.models.user import User +from app.schemas.user import ( + CreateEmergencyContactRequest, + DeleteContactResponse, + EmergencyContactResponse, + UpdateEmergencyContactRequest, +) +from app.services.user import UserService logger = logging.getLogger(__name__) router = APIRouter(prefix="/users", tags=["Users"]) -class CreateEmergencyContactRequest(BaseModel): - name: str - relationship: str - phone_number: str - is_primary: bool = False - - -class UpdateEmergencyContactRequest(BaseModel): - name: str | None = None - relationship: str | None = None - phone_number: str | None = None - is_primary: bool | None = None - - -async def _get_current_user( - authorization: str | None = Header(None), - db: AsyncSession = Depends(get_db_session), -) -> User: - if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Header Authorization Bearer diperlukan", - ) - - token = authorization.split("Bearer ")[1].strip() - decoded = decode_access_token(token) - if not decoded or "sub" not in decoded: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token akses tidak valid atau telah kedaluwarsa", - ) - - try: - u_uuid = uuid.UUID(decoded["sub"]) - except (ValueError, TypeError): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Format user ID dalam token tidak valid", - ) - - query_jwt = select(User).where(User.id == u_uuid) - res_jwt = await db.execute(query_jwt) - user = res_jwt.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Pengguna tidak ditemukan atau telah dinonaktifkan", - ) - - return user - - -@router.get("/emergency-contacts") +@router.get("/emergency-contacts", response_model=list[EmergencyContactResponse]) async def get_emergency_contacts( - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> list[dict[str, Any]]: - query = select(EmergencyContact).where(EmergencyContact.user_id == user.id) - res = await db.execute(query) - contacts = res.scalars().all() + user: User = Depends(get_current_user), + service: UserService = Depends(get_user_service), +) -> list[EmergencyContactResponse]: + """Retrieve all emergency contacts for the authenticated user.""" + return await service.get_emergency_contacts(user.id) - return [ - { - "id": str(c.id), - "name": c.name, - "relationship": c.relationship, - "phone": c.phone_number, - "isPrimary": c.is_primary, - } - for c in contacts - ] - -@router.post("/emergency-contacts") +@router.post("/emergency-contacts", response_model=EmergencyContactResponse) async def create_emergency_contact( payload: CreateEmergencyContactRequest, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - # If new contact is primary, unset others - if payload.is_primary: - existing_q = select(EmergencyContact).where( - EmergencyContact.user_id == user.id, - EmergencyContact.is_primary == True, # noqa: E712 - ) - existing_res = await db.execute(existing_q) - for c in existing_res.scalars().all(): - c.is_primary = False - - contact = EmergencyContact( - user_id=user.id, - name=payload.name, - relationship=payload.relationship, - phone_number=payload.phone_number, - is_primary=payload.is_primary, - ) - db.add(contact) - await db.commit() - await db.refresh(contact) - - return { - "id": str(contact.id), - "name": contact.name, - "relationship": contact.relationship, - "phone": contact.phone_number, - "isPrimary": contact.is_primary, - } + user: User = Depends(get_current_user), + service: UserService = Depends(get_user_service), +) -> EmergencyContactResponse: + """Create a new emergency contact for the authenticated user.""" + return await service.create_emergency_contact(user.id, payload) -@router.put("/emergency-contacts/{contact_id}") +@router.put("/emergency-contacts/{contact_id}", response_model=EmergencyContactResponse) async def update_emergency_contact( contact_id: str, payload: UpdateEmergencyContactRequest, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, Any]: - try: - c_uuid = uuid.UUID(contact_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid contact ID") - - query = select(EmergencyContact).where( - EmergencyContact.id == c_uuid, - EmergencyContact.user_id == user.id, # ensure ownership - ) - res = await db.execute(query) - contact = res.scalar_one_or_none() - - if not contact: - raise HTTPException(status_code=404, detail="Contact not found") - - if payload.name is not None: - contact.name = payload.name - if payload.relationship is not None: - contact.relationship = payload.relationship - if payload.phone_number is not None: - contact.phone_number = payload.phone_number - if payload.is_primary is not None: - if payload.is_primary: - # Unset other primary contacts - existing_q = select(EmergencyContact).where( - EmergencyContact.user_id == user.id, - EmergencyContact.is_primary == True, # noqa: E712 - ) - existing_res = await db.execute(existing_q) - for c in existing_res.scalars().all(): - c.is_primary = False - contact.is_primary = payload.is_primary + user: User = Depends(get_current_user), + service: UserService = Depends(get_user_service), +) -> EmergencyContactResponse: + """Update existing emergency contact details.""" + return await service.update_emergency_contact(user.id, contact_id, payload) - await db.commit() - await db.refresh(contact) - return { - "id": str(contact.id), - "name": contact.name, - "relationship": contact.relationship, - "phone": contact.phone_number, - "isPrimary": contact.is_primary, - } - - -@router.delete("/emergency-contacts/{contact_id}") +@router.delete("/emergency-contacts/{contact_id}", response_model=DeleteContactResponse) async def delete_emergency_contact( contact_id: str, - user: User = Depends(_get_current_user), - db: AsyncSession = Depends(get_db_session), -) -> dict[str, str]: - try: - c_uuid = uuid.UUID(contact_id) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid contact ID") - - query = select(EmergencyContact).where( - EmergencyContact.id == c_uuid, - EmergencyContact.user_id == user.id, - ) - res = await db.execute(query) - contact = res.scalar_one_or_none() - - if not contact: - raise HTTPException(status_code=404, detail="Contact not found") - - await db.delete(contact) - await db.commit() - return {"message": "Contact deleted successfully"} + user: User = Depends(get_current_user), + service: UserService = Depends(get_user_service), +) -> DeleteContactResponse: + """Delete an emergency contact owned by the user.""" + return await service.delete_emergency_contact(user.id, contact_id) diff --git a/apps/backend/api/app/schemas/analytics/__init__.py b/apps/backend/api/app/schemas/analytics/__init__.py new file mode 100644 index 0000000..f7489df --- /dev/null +++ b/apps/backend/api/app/schemas/analytics/__init__.py @@ -0,0 +1,11 @@ +from app.schemas.analytics.responses import ( + EmotionalCenterResponse, + MonitoringDataResponse, + RiskItemResponse, +) + +__all__ = [ + "EmotionalCenterResponse", + "RiskItemResponse", + "MonitoringDataResponse", +] diff --git a/apps/backend/api/app/schemas/analytics/responses.py b/apps/backend/api/app/schemas/analytics/responses.py new file mode 100644 index 0000000..7233e0d --- /dev/null +++ b/apps/backend/api/app/schemas/analytics/responses.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, ConfigDict + + +class EmotionalCenterResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + status: str + level: int + description: str + textColorHex: str + + +class RiskItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + name: str + type: str + percent: float + levelLabel: str + colorHex: str + badgeBgHex: str + + +class MonitoringDataResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + periodKey: str + periodLabel: str + summary: str + emotionalCenter: EmotionalCenterResponse + risks: list[RiskItemResponse] + xLabels: list[str] + chartData: list[list[float]] diff --git a/apps/backend/api/app/schemas/auth/__init__.py b/apps/backend/api/app/schemas/auth/__init__.py new file mode 100644 index 0000000..64743ce --- /dev/null +++ b/apps/backend/api/app/schemas/auth/__init__.py @@ -0,0 +1,17 @@ +from app.schemas.auth.requests import LoginRequest, RefreshRequest, RegisterRequest +from app.schemas.auth.responses import ( + EmailCheckResponse, + LogoutResponse, + TokenResponse, + UserAuthResponse, +) + +__all__ = [ + "LoginRequest", + "RegisterRequest", + "RefreshRequest", + "UserAuthResponse", + "TokenResponse", + "EmailCheckResponse", + "LogoutResponse", +] diff --git a/apps/backend/api/app/schemas/auth/requests.py b/apps/backend/api/app/schemas/auth/requests.py new file mode 100644 index 0000000..94becf6 --- /dev/null +++ b/apps/backend/api/app/schemas/auth/requests.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, EmailStr + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class RegisterRequest(BaseModel): + name: str + email: EmailStr + password: str + + +class RefreshRequest(BaseModel): + refresh_token: str diff --git a/apps/backend/api/app/schemas/auth/responses.py b/apps/backend/api/app/schemas/auth/responses.py new file mode 100644 index 0000000..bbfc0ae --- /dev/null +++ b/apps/backend/api/app/schemas/auth/responses.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, ConfigDict + + +class UserAuthResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + name: str + email: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + user: UserAuthResponse + + +class EmailCheckResponse(BaseModel): + available: bool + email: str + message: str + + +class LogoutResponse(BaseModel): + message: str = "Logged out successfully" diff --git a/apps/backend/api/app/schemas/conversation/__init__.py b/apps/backend/api/app/schemas/conversation/__init__.py new file mode 100644 index 0000000..1b4542f --- /dev/null +++ b/apps/backend/api/app/schemas/conversation/__init__.py @@ -0,0 +1,31 @@ +from app.schemas.conversation.requests import SendMessageRequest +from app.schemas.conversation.responses import ( + ConversationAnalysis, + ConversationDetailResponse, + ConversationListResponse, + ConversationPagination, + EmotionBreakdownSimple, + MessageItemResponse, + SentMessageResponse, + TodayConversationItem, + TodayConversationResponse, + TodayMessageItem, + TodayMessagesResponse, + TranscriptItem, +) + +__all__ = [ + "SendMessageRequest", + "MessageItemResponse", + "ConversationDetailResponse", + "ConversationPagination", + "ConversationListResponse", + "TranscriptItem", + "EmotionBreakdownSimple", + "ConversationAnalysis", + "TodayConversationItem", + "TodayConversationResponse", + "TodayMessageItem", + "TodayMessagesResponse", + "SentMessageResponse", +] diff --git a/apps/backend/api/app/schemas/conversation/requests.py b/apps/backend/api/app/schemas/conversation/requests.py new file mode 100644 index 0000000..647fdf2 --- /dev/null +++ b/apps/backend/api/app/schemas/conversation/requests.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class SendMessageRequest(BaseModel): + content: str + modality: str = "text" diff --git a/apps/backend/api/app/schemas/conversation/responses.py b/apps/backend/api/app/schemas/conversation/responses.py new file mode 100644 index 0000000..047929a --- /dev/null +++ b/apps/backend/api/app/schemas/conversation/responses.py @@ -0,0 +1,117 @@ +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class MessageItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + sender: str + text: str + time: str + isAudio: bool = False + emotionTag: str | None = None + emotionEmoji: str | None = None + + +class ConversationDetailResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + title: str + is_title_generating: bool = False + lastMessage: str = "" + lastMessageTime: str = "" + duration: str = "00:00" + dominant_emotion: str = "Tenang & Nyaman" + dominant_emoji: str = "😌" + summary: str | None = None + messages: list[MessageItemResponse] = [] + + +class ConversationPagination(BaseModel): + page: int + limit: int + total_items: int + total_pages: int + has_more: bool + + +class ConversationListResponse(BaseModel): + items: list[ConversationDetailResponse] + pagination: ConversationPagination + total: int + page: int + limit: int + has_more: bool + + +class TranscriptItem(BaseModel): + role: str + content: str + time: str + emotionTag: str | None = None + emotionEmoji: str | None = None + + +class EmotionBreakdownSimple(BaseModel): + label: str + emoji: str + percent: float + color: str + + +class ConversationAnalysis(BaseModel): + dominant_emotion: str + calm_score: str + stress_level: str + empathy_level: str + ai_insight: str + emotions_breakdown: list[EmotionBreakdownSimple] = [] + + +class TodayConversationItem(BaseModel): + id: str + title: str + is_title_generating: bool = False + date: str + duration: str + moodTag: str + emotion_analysis: ConversationAnalysis + transcript: list[TranscriptItem] = [] + + +class TodayConversationResponse(BaseModel): + items: list[TodayConversationItem] + total: int + page: int + limit: int + has_more: bool + + +class TodayMessageItem(BaseModel): + id: str + role: str + isUser: bool + content: str + text: str + time: str + sessionTitle: str + emotionTag: str | None = None + emotionEmoji: str | None = None + + +class TodayMessagesResponse(BaseModel): + items: list[TodayMessageItem] + total_items: int + page: int + limit: int + total_pages: int + + +class SentMessageResponse(BaseModel): + id: str + sender: str + text: str + time: str + isAudio: bool = False diff --git a/apps/backend/api/app/schemas/dass/__init__.py b/apps/backend/api/app/schemas/dass/__init__.py new file mode 100644 index 0000000..9969f81 --- /dev/null +++ b/apps/backend/api/app/schemas/dass/__init__.py @@ -0,0 +1,14 @@ +from app.schemas.dass.requests import DASSAssessmentUpdate, DASSItemUpdate +from app.schemas.dass.responses import ( + DASSAssessmentResponse, + DASSItemSchema, + SubscaleScoreSummary, +) + +__all__ = [ + "DASSItemUpdate", + "DASSAssessmentUpdate", + "DASSItemSchema", + "SubscaleScoreSummary", + "DASSAssessmentResponse", +] diff --git a/apps/backend/api/app/schemas/dass/requests.py b/apps/backend/api/app/schemas/dass/requests.py new file mode 100644 index 0000000..ef1e201 --- /dev/null +++ b/apps/backend/api/app/schemas/dass/requests.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field + + +class DASSItemUpdate(BaseModel): + item_id: int = Field(..., ge=1, le=21) + score: int = Field(..., ge=0, le=3) + + +class DASSAssessmentUpdate(BaseModel): + items: list[DASSItemUpdate] = Field(..., min_length=1, max_length=21) diff --git a/apps/backend/api/app/schemas/dass.py b/apps/backend/api/app/schemas/dass/responses.py similarity index 85% rename from apps/backend/api/app/schemas/dass.py rename to apps/backend/api/app/schemas/dass/responses.py index 0ecc624..29d13c0 100644 --- a/apps/backend/api/app/schemas/dass.py +++ b/apps/backend/api/app/schemas/dass/responses.py @@ -15,15 +15,6 @@ class DASSItemSchema(BaseModel): is_user_edited: bool = Field(False, description="Flag apakah skor butir ini telah diedit oleh user") -class DASSItemUpdate(BaseModel): - item_id: int = Field(..., ge=1, le=21) - score: int = Field(..., ge=0, le=3) - - -class DASSAssessmentUpdate(BaseModel): - items: list[DASSItemUpdate] = Field(..., min_length=1, max_length=21) - - class SubscaleScoreSummary(BaseModel): raw_sum: int final_score: int @@ -31,6 +22,8 @@ class SubscaleScoreSummary(BaseModel): class DASSAssessmentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID | None = None user_id: UUID conversation_id: UUID | None = None @@ -44,5 +37,3 @@ class DASSAssessmentResponse(BaseModel): verified_by_user: bool status: str items: list[DASSItemSchema] - - model_config = ConfigDict(from_attributes=True) diff --git a/apps/backend/api/app/schemas/diary/__init__.py b/apps/backend/api/app/schemas/diary/__init__.py new file mode 100644 index 0000000..9b12e80 --- /dev/null +++ b/apps/backend/api/app/schemas/diary/__init__.py @@ -0,0 +1,20 @@ +from app.schemas.diary.requests import CreateDiaryRequest, UpdateDiaryRequest +from app.schemas.diary.responses import ( + DeleteDiaryResponse, + DiaryEntryResponse, + DiaryRiskWarning, + DiarySessionItem, + EmotionBreakdownItem, + TranscriptItem, +) + +__all__ = [ + "CreateDiaryRequest", + "UpdateDiaryRequest", + "EmotionBreakdownItem", + "TranscriptItem", + "DiarySessionItem", + "DiaryRiskWarning", + "DiaryEntryResponse", + "DeleteDiaryResponse", +] diff --git a/apps/backend/api/app/schemas/diary/requests.py b/apps/backend/api/app/schemas/diary/requests.py new file mode 100644 index 0000000..ad4f308 --- /dev/null +++ b/apps/backend/api/app/schemas/diary/requests.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class CreateDiaryRequest(BaseModel): + content: str + mood_tag: str | None = None + + +class UpdateDiaryRequest(BaseModel): + content: str | None = None + mood_tag: str | None = None + title: str | None = None diff --git a/apps/backend/api/app/schemas/diary/responses.py b/apps/backend/api/app/schemas/diary/responses.py new file mode 100644 index 0000000..7913496 --- /dev/null +++ b/apps/backend/api/app/schemas/diary/responses.py @@ -0,0 +1,61 @@ +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class EmotionBreakdownItem(BaseModel): + name: str + label: str + emoji: str + percent: float + color: str + + +class TranscriptItem(BaseModel): + isUser: bool + time: str + text: str + emotionTag: str | None = None + emotionEmoji: str | None = None + + +class DiarySessionItem(BaseModel): + id: str + title: str + time: str + moodTag: str + moodEmoji: str + emotionsBreakdown: list[EmotionBreakdownItem] = [] + transcripts: list[TranscriptItem] = [] + + +class DiaryRiskWarning(BaseModel): + detected: bool + type: str + title: str + level: str + message: str + + +class DiaryEntryResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str | None = None + title: str | None = None + date: str | None = None + sessionCount: int = 0 + lastSessionTime: str = "-" + moodTag: str = "Netral" + moodEmoji: str = "😌" + summary: str = "" + riskWarning: DiaryRiskWarning | None = None + aiInsight: str = "" + importantEvents: list[str] = [] + emotionalReflection: str = "" + mentalHealthScores: dict[str, Any] = {} + emotionsBreakdown: list[EmotionBreakdownItem] = [] + sessions: list[DiarySessionItem] = [] + message: str | None = None + + +class DeleteDiaryResponse(BaseModel): + message: str = "Diary entry deleted successfully" diff --git a/apps/backend/api/app/schemas/health/__init__.py b/apps/backend/api/app/schemas/health/__init__.py new file mode 100644 index 0000000..d6740ce --- /dev/null +++ b/apps/backend/api/app/schemas/health/__init__.py @@ -0,0 +1,3 @@ +from app.schemas.health.responses import HealthCheckResponse + +__all__ = ["HealthCheckResponse"] diff --git a/apps/backend/api/app/schemas/health.py b/apps/backend/api/app/schemas/health/responses.py similarity index 59% rename from apps/backend/api/app/schemas/health.py rename to apps/backend/api/app/schemas/health/responses.py index cb4c15b..cc51250 100644 --- a/apps/backend/api/app/schemas/health.py +++ b/apps/backend/api/app/schemas/health/responses.py @@ -1,7 +1,9 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class HealthCheckResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + status: str app: str environment: str diff --git a/apps/backend/api/app/schemas/recommendation/__init__.py b/apps/backend/api/app/schemas/recommendation/__init__.py new file mode 100644 index 0000000..81ca16a --- /dev/null +++ b/apps/backend/api/app/schemas/recommendation/__init__.py @@ -0,0 +1,11 @@ +from app.schemas.recommendation.responses import ( + CompleteActivityResponse, + CopingActivityResponse, + TodayRecommendationResponse, +) + +__all__ = [ + "CopingActivityResponse", + "TodayRecommendationResponse", + "CompleteActivityResponse", +] diff --git a/apps/backend/api/app/schemas/recommendation/responses.py b/apps/backend/api/app/schemas/recommendation/responses.py new file mode 100644 index 0000000..d7d3551 --- /dev/null +++ b/apps/backend/api/app/schemas/recommendation/responses.py @@ -0,0 +1,40 @@ +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class CopingActivityResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + title: str + category: str + targetCondition: str + duration: str + difficulty: str + level: str + description: str + instructions: list[str] = [] + rationale: str = "" + iconName: str = "spa" + isCompleted: bool = False + + +class TodayRecommendationResponse(BaseModel): + targetCondition: str + conditionLabel: str + conditionReason: str + severityLevel: str + isCrisis: bool + crisisHotline: str + crisisGuidance: str + todayActivity: CopingActivityResponse + completedTodayCount: int + alternatives: list[CopingActivityResponse] = [] + allRecommendations: list[CopingActivityResponse] = [] + + +class CompleteActivityResponse(BaseModel): + message: str + activityId: str + completedDate: str | None = None + isCompleted: bool = True diff --git a/apps/backend/api/app/schemas/user/__init__.py b/apps/backend/api/app/schemas/user/__init__.py new file mode 100644 index 0000000..47e3f26 --- /dev/null +++ b/apps/backend/api/app/schemas/user/__init__.py @@ -0,0 +1,15 @@ +from app.schemas.user.requests import ( + CreateEmergencyContactRequest, + UpdateEmergencyContactRequest, +) +from app.schemas.user.responses import ( + DeleteContactResponse, + EmergencyContactResponse, +) + +__all__ = [ + "CreateEmergencyContactRequest", + "UpdateEmergencyContactRequest", + "EmergencyContactResponse", + "DeleteContactResponse", +] diff --git a/apps/backend/api/app/schemas/user/requests.py b/apps/backend/api/app/schemas/user/requests.py new file mode 100644 index 0000000..046b353 --- /dev/null +++ b/apps/backend/api/app/schemas/user/requests.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class CreateEmergencyContactRequest(BaseModel): + name: str + relationship: str + phone_number: str + is_primary: bool = False + + +class UpdateEmergencyContactRequest(BaseModel): + name: str | None = None + relationship: str | None = None + phone_number: str | None = None + is_primary: bool | None = None diff --git a/apps/backend/api/app/schemas/user/responses.py b/apps/backend/api/app/schemas/user/responses.py new file mode 100644 index 0000000..bb45052 --- /dev/null +++ b/apps/backend/api/app/schemas/user/responses.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, ConfigDict + + +class EmergencyContactResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + name: str + relationship: str + phone: str + isPrimary: bool + + +class DeleteContactResponse(BaseModel): + message: str = "Contact deleted successfully" diff --git a/apps/backend/api/app/services/analytics/__init__.py b/apps/backend/api/app/services/analytics/__init__.py new file mode 100644 index 0000000..12ac2d0 --- /dev/null +++ b/apps/backend/api/app/services/analytics/__init__.py @@ -0,0 +1,3 @@ +from app.services.analytics.analytics_service import AnalyticsService + +__all__ = ["AnalyticsService"] diff --git a/apps/backend/api/app/services/analytics_service.py b/apps/backend/api/app/services/analytics/analytics_service.py similarity index 98% rename from apps/backend/api/app/services/analytics_service.py rename to apps/backend/api/app/services/analytics/analytics_service.py index ae46432..fee429f 100644 --- a/apps/backend/api/app/services/analytics_service.py +++ b/apps/backend/api/app/services/analytics/analytics_service.py @@ -13,6 +13,7 @@ from app.models.dass import DASSAssessment from app.models.diary import DiaryEntry from app.models.safety import EmotionAnalysis +from app.schemas.analytics import MonitoringDataResponse logger = logging.getLogger(__name__) @@ -22,6 +23,15 @@ class AnalyticsService: for the Monitoring/Tren screen across 'today', 'week', and 'month' periods. """ + def __init__(self, db: AsyncSession): + self.db = db + + async def get_data(self, user_id: uuid.UUID, period: str = "today") -> MonitoringDataResponse: + """Fetch monitoring data for the user and return structured Pydantic response.""" + raw = await self.get_monitoring_data(user_id, period, self.db) + return MonitoringDataResponse.model_validate(raw) + + # 7 UI Emotions index mapping: # 0: happy, 1: netral, 2: fear, 3: sadness, 4: surprise, 5: anger, 6: disgusted EMOTION_MAP = { diff --git a/apps/backend/api/app/services/auth/__init__.py b/apps/backend/api/app/services/auth/__init__.py new file mode 100644 index 0000000..db94126 --- /dev/null +++ b/apps/backend/api/app/services/auth/__init__.py @@ -0,0 +1,3 @@ +from app.services.auth.auth_service import AuthService + +__all__ = ["AuthService"] diff --git a/apps/backend/api/app/services/auth/auth_service.py b/apps/backend/api/app/services/auth/auth_service.py new file mode 100644 index 0000000..cab51ec --- /dev/null +++ b/apps/backend/api/app/services/auth/auth_service.py @@ -0,0 +1,217 @@ +import logging +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import ( + create_access_token, + create_refresh_token, + decode_access_token, + decode_refresh_token, + hash_password, + verify_password, +) +from app.models.user import User +from app.schemas.auth import ( + EmailCheckResponse, + LoginRequest, + LogoutResponse, + RefreshRequest, + RegisterRequest, + TokenResponse, + UserAuthResponse, +) + +logger = logging.getLogger(__name__) + + +class AuthService: + """Service handling user authentication, registration, and token lifecycles.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def check_email(self, email: str) -> EmailCheckResponse: + """Check if an email is valid and available for registration.""" + clean_email = email.strip().lower() + if not clean_email or "@" not in clean_email or "." not in clean_email: + return EmailCheckResponse( + available=False, + email=clean_email, + message="Format email tidak valid", + ) + + query = select(User).where(User.email == clean_email) + res = await self.db.execute(query) + existing = res.scalar_one_or_none() + + if existing: + return EmailCheckResponse( + available=False, + email=clean_email, + message="Email sudah terdaftar", + ) + + return EmailCheckResponse( + available=True, + email=clean_email, + message="Email tersedia", + ) + + async def login(self, payload: LoginRequest) -> TokenResponse: + """Authenticate user by email and password, returning JWT access & refresh tokens.""" + clean_email = payload.email.strip().lower() + query = select(User).where(User.email == clean_email) + res = await self.db.execute(query) + user = res.scalar_one_or_none() + + if not user or not verify_password(payload.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Email atau kata sandi tidak valid", + ) + + display_name = user.display_name or user.username or "User Eva" + access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) + refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + user=UserAuthResponse( + id=str(user.id), + name=display_name, + email=user.email, + ), + ) + + async def register(self, payload: RegisterRequest) -> TokenResponse: + """Register a new user, commit to database, and return initial JWT tokens.""" + clean_email = payload.email.strip().lower() + query = select(User).where(User.email == clean_email) + res = await self.db.execute(query) + existing = res.scalar_one_or_none() + + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email sudah terdaftar", + ) + + clean_name = payload.name.strip() + user = User( + email=clean_email, + username=clean_name, + display_name=clean_name, + password_hash=hash_password(payload.password), + is_active=True, + is_verified=True, + ) + self.db.add(user) + await self.db.commit() + await self.db.refresh(user) + + display_name = user.display_name or clean_name + access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) + refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) + + logger.info(f"✅ [AUTH] User registered successfully: {clean_email} ({user.id})") + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer", + user=UserAuthResponse( + id=str(user.id), + name=display_name, + email=user.email, + ), + ) + + async def refresh_token(self, payload: RefreshRequest) -> TokenResponse: + """Issue a new access and refresh token pair using a valid refresh token.""" + decoded = decode_refresh_token(payload.refresh_token) + if not decoded or "sub" not in decoded: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token tidak valid atau telah kedaluwarsa", + ) + + try: + u_uuid = uuid.UUID(decoded["sub"]) + except ValueError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Format user ID token tidak valid", + ) + + query = select(User).where(User.id == u_uuid) + res = await self.db.execute(query) + user = res.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User tidak ditemukan atau tidak aktif", + ) + + display_name = user.display_name or user.username or "User Eva" + new_access_token = create_access_token(user_id=str(user.id), email=user.email, name=display_name) + new_refresh_token = create_refresh_token(user_id=str(user.id), email=user.email) + + return TokenResponse( + access_token=new_access_token, + refresh_token=new_refresh_token, + token_type="bearer", + user=UserAuthResponse( + id=str(user.id), + name=display_name, + email=user.email, + ), + ) + + async def get_user_from_token(self, token: str) -> User: + """Decode access token, parse user UUID, and retrieve User model from database.""" + decoded = decode_access_token(token) + if not decoded or "sub" not in decoded: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token akses tidak valid atau telah kedaluwarsa", + ) + + try: + user_uuid = uuid.UUID(decoded["sub"]) + except (ValueError, TypeError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Format user ID dalam token tidak valid", + ) + + query = select(User).where(User.id == user_uuid) + res = await self.db.execute(query) + user = res.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Pengguna tidak ditemukan atau telah dinonaktifkan", + ) + + return user + + @staticmethod + def get_user_profile(user: User) -> UserAuthResponse: + """Format an authenticated User instance into a UserAuthResponse DTO.""" + return UserAuthResponse( + id=str(user.id), + name=user.display_name or user.username or "User Eva", + email=user.email, + ) + + @staticmethod + def logout() -> LogoutResponse: + """Return successful logout acknowledgment.""" + return LogoutResponse() diff --git a/apps/backend/api/app/services/call/__init__.py b/apps/backend/api/app/services/call/__init__.py new file mode 100644 index 0000000..964ea1d --- /dev/null +++ b/apps/backend/api/app/services/call/__init__.py @@ -0,0 +1,33 @@ +from app.services.call.persistence import CallPersistenceService +from app.services.call.session_manager import CallSession, CallSessionManager, call_session_manager +from app.services.call.stream_buffer import ( + AudioStreamBufferService, + SessionAudioBuffer, + analyze_audio_placeholder, + audio_buffer_service, +) +from app.services.call.turn_aggregator import ( + TurnAggregator, + merge_overlapping_segments, +) +from app.services.call.turn_detector import ( + TurnDecisionType, + TurnDetectionResult, + TurnDetectionService, +) + +__all__ = [ + "CallSession", + "CallSessionManager", + "call_session_manager", + "CallPersistenceService", + "AudioStreamBufferService", + "SessionAudioBuffer", + "audio_buffer_service", + "analyze_audio_placeholder", + "TurnAggregator", + "merge_overlapping_segments", + "TurnDetectionResult", + "TurnDetectionService", + "TurnDecisionType", +] diff --git a/apps/backend/api/app/services/call_persistence_service.py b/apps/backend/api/app/services/call/persistence.py similarity index 97% rename from apps/backend/api/app/services/call_persistence_service.py rename to apps/backend/api/app/services/call/persistence.py index 7443466..bd083f1 100644 --- a/apps/backend/api/app/services/call_persistence_service.py +++ b/apps/backend/api/app/services/call/persistence.py @@ -12,11 +12,10 @@ from app.models.conversation import Conversation, ConversationSummary, Message, MessageType from app.models.dass import DASSAssessment from app.models.user import User -from app.services.audio_stream_buffer import audio_buffer_service -from app.services.dass_service import DASSService -from app.services.diary_generator import DiaryGeneratorService -from app.services.emotion_analyzer import EmotionAnalyzerService -from app.services.ml_emotion_detector import MLEmotionDetectorService +from app.services.call.stream_buffer import audio_buffer_service +from app.services.dass import DASSService +from app.services.diary import DiaryGeneratorService +from app.services.emotion import EmotionAnalyzerService, MLEmotionDetectorService from packages.ai.services.dass_extraction_service import DASSExtractionService logger = logging.getLogger(__name__) diff --git a/apps/backend/api/app/services/call_session_manager.py b/apps/backend/api/app/services/call/session_manager.py similarity index 93% rename from apps/backend/api/app/services/call_session_manager.py rename to apps/backend/api/app/services/call/session_manager.py index b5d09b0..9a98bb7 100644 --- a/apps/backend/api/app/services/call_session_manager.py +++ b/apps/backend/api/app/services/call/session_manager.py @@ -8,7 +8,7 @@ from typing import Any import uuid -from fastapi import WebSocket +from fastapi import WebSocket, WebSocketDisconnect from sqlalchemy import desc, select from sqlalchemy.orm import selectinload @@ -23,13 +23,12 @@ from app.models.diary import DiaryEntry from app.models.enums import MessageType from app.models.user import User -from app.services.audio_stream_buffer import audio_buffer_service -from app.services.call_persistence_service import CallPersistenceService -from app.services.dass_service import DASSService -from app.services.diary_generator import DiaryGeneratorService -from app.services.emotion_analyzer import EmotionAnalyzerService -from app.services.ml_emotion_detector import MLEmotionDetectorService -from app.services.turn_aggregator import TurnAggregator +from app.services.call.persistence import CallPersistenceService +from app.services.call.stream_buffer import audio_buffer_service +from app.services.call.turn_aggregator import TurnAggregator +from app.services.dass import DASSService +from app.services.diary import DiaryGeneratorService +from app.services.emotion import EmotionAnalyzerService, MLEmotionDetectorService from packages.ai.factories.llm_factory import LLMFactory from packages.ai.factories.tts_factory import TTSFactory from packages.ai.interfaces.llm import LLMMessage @@ -148,6 +147,60 @@ def unregister_session(self, session_id: str) -> None: def get_session(self, session_id: str) -> CallSession | None: return self._active_sessions.get(session_id) + async def handle_connection( + self, + websocket: WebSocket, + session_id: str, + token: str | None = None, + voice_mode: str | None = None, + ) -> None: + """Handle entire bi-directional WebSocket connection lifecycle for AI Phone Call sessions.""" + await websocket.accept() + session = self.register_session(session_id, websocket, auth_token=token, voice_mode=voice_mode) + logger.info(f"🔌 [WEBSOCKET CONNECTED] Session: {session_id} (Voice Mode: {session.voice_mode})") + + try: + # Pre-load previous conversation history from DB so DeepSeek LLM remembers past context + await self.load_conversation_history(session) + + # Notify client connection accepted + await websocket.send_json({ + "type": "call_connected", + "session_id": session_id, + "message": "Connected to Eva AI Phone Service", + }) + + while True: + message = await websocket.receive() + if "bytes" in message and message["bytes"]: + audio_bytes = message["bytes"] + logger.info(f"🎙️ [INCOMING MIC AUDIO] Session {session_id}: Received {len(audio_bytes)} raw mic bytes") + await audio_buffer_service.handle_incoming_audio_chunk(session_id, audio_bytes) + + elif "text" in message and message["text"]: + try: + payload = json.loads(message["text"]) + event_type = payload.get("type") + + if event_type == "end_call": + duration_sec = payload.get("duration_seconds") + logger.info(f"🛑 [CALL ENDED BY USER] Session {session_id}, Duration: {duration_sec}s") + await self.update_session_end(session_id, duration_seconds=duration_sec) + break + + await self.handle_event(session, payload) + + except json.JSONDecodeError: + logger.warning(f"⚠️ [INVALID JSON] Session {session_id}: Received malformed JSON") + + except WebSocketDisconnect: + logger.info(f"🔌 [WEBSOCKET DISCONNECTED] Session {session_id}") + except Exception as e: + logger.error(f"❌ [WEBSOCKET ERROR] Session {session_id}: {e}") + finally: + self.unregister_session(session_id) + audio_buffer_service.clear_session(session_id) + async def load_conversation_history(self, session: CallSession) -> None: await CallPersistenceService.load_conversation_history(session) diff --git a/apps/backend/api/app/services/audio_stream_buffer.py b/apps/backend/api/app/services/call/stream_buffer.py similarity index 100% rename from apps/backend/api/app/services/audio_stream_buffer.py rename to apps/backend/api/app/services/call/stream_buffer.py diff --git a/apps/backend/api/app/services/turn_aggregator.py b/apps/backend/api/app/services/call/turn_aggregator.py similarity index 98% rename from apps/backend/api/app/services/turn_aggregator.py rename to apps/backend/api/app/services/call/turn_aggregator.py index ed9d24e..1d89c9b 100644 --- a/apps/backend/api/app/services/turn_aggregator.py +++ b/apps/backend/api/app/services/call/turn_aggregator.py @@ -4,7 +4,7 @@ from typing import Any from app.core.call_config import CallTurnConfig, call_config -from app.services.turn_detection_service import TurnDetectionResult, TurnDetectionService +from app.services.call.turn_detector import TurnDetectionResult, TurnDetectionService logger = logging.getLogger(__name__) diff --git a/apps/backend/api/app/services/turn_detection_service.py b/apps/backend/api/app/services/call/turn_detector.py similarity index 100% rename from apps/backend/api/app/services/turn_detection_service.py rename to apps/backend/api/app/services/call/turn_detector.py diff --git a/apps/backend/api/app/services/conversation/__init__.py b/apps/backend/api/app/services/conversation/__init__.py new file mode 100644 index 0000000..f31bdc8 --- /dev/null +++ b/apps/backend/api/app/services/conversation/__init__.py @@ -0,0 +1,19 @@ +from app.services.conversation.conversation_service import ( + ConversationService, + analyze_message_emotion_fallback, + format_conversation, + format_duration_str, + format_time_local, + get_message_emotion, +) +from app.services.conversation.summary_stream_service import SummaryStreamService + +__all__ = [ + "ConversationService", + "SummaryStreamService", + "format_time_local", + "format_duration_str", + "analyze_message_emotion_fallback", + "get_message_emotion", + "format_conversation", +] diff --git a/apps/backend/api/app/services/conversation/conversation_service.py b/apps/backend/api/app/services/conversation/conversation_service.py new file mode 100644 index 0000000..c89fe9b --- /dev/null +++ b/apps/backend/api/app/services/conversation/conversation_service.py @@ -0,0 +1,536 @@ +from collections import Counter +from datetime import datetime, timezone +import logging +import math +from typing import Any +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.tz import get_wib_day_range_utc, get_wib_today, to_wib +from app.models.conversation import Conversation, ConversationSummary, Message +from app.models.diary import DiaryEntry +from app.models.enums import MessageType +from app.models.safety import EmotionAnalysis +from app.schemas.conversation import ( + ConversationAnalysis, + ConversationDetailResponse, + ConversationListResponse, + ConversationPagination, + EmotionBreakdownSimple, + MessageItemResponse, + SendMessageRequest, + SentMessageResponse, + TodayConversationItem, + TodayConversationResponse, + TodayMessageItem, + TodayMessagesResponse, + TranscriptItem, +) +from app.services.emotion import MLEmotionDetectorService + +logger = logging.getLogger(__name__) + + +def format_time_local(dt: datetime | None) -> str: + """Format datetime into 12-hour local time format (hh:mm AM/PM).""" + if not dt: + return "09:15 AM" + try: + dt_wib = to_wib(dt) + return dt_wib.strftime("%I:%M %p") if dt_wib else "09:15 AM" + except Exception: + return dt.strftime("%I:%M %p") + + +def format_duration_str(started_at: datetime | None, last_message_at: datetime | None) -> str: + """Calculate elapsed session duration and format as MM:SS.""" + if not started_at or not last_message_at: + return "00:00" + try: + t1 = started_at if started_at.tzinfo else started_at.replace(tzinfo=timezone.utc) + t2 = last_message_at if last_message_at.tzinfo else last_message_at.replace(tzinfo=timezone.utc) + diff = int((t2 - t1).total_seconds()) + if diff < 0: + diff = 0 + mins = diff // 60 + secs = diff % 60 + return f"{mins:02d}:{secs:02d}" + except Exception: + return "00:00" + + +def analyze_message_emotion_fallback(text: str) -> tuple[str, str]: + """Determine emotion tag and emoji dynamically from message text keywords.""" + t_low = (text or "").lower() + anx_words = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup", "takutnya", "bingung", "was-was", "tegang"] + dep_words = ["sedih", "nangis", "menangis", "hampa", "sendiri", "kehilangan", "terpuruk", "putus asa", "kecewa", "patah hati", "bunuh diri"] + str_words = ["stres", "capek", "lelah", "beban", "berat", "penat", "pusing", "mumet", "tekanan", "tugas", "deadline", "kerjaan", "letih"] + ang_words = ["marah", "kesal", "jengkel", "benci", "emosi", "sebal", "kesel", "geram", "murka"] + pos_words = ["lega", "senang", "bahagia", "terima kasih", "makasih", "enakan", "tenang", "nyaman", "santai", "alhamdulillah", "syukurlah", "baik", "bersyukur"] + + if any(w in t_low for w in anx_words): + return "fear (78%)", "😨" + if any(w in t_low for w in dep_words): + return "sadness (82%)", "😔" + if any(w in t_low for w in str_words): + return "stress (75%)", "💥" + if any(w in t_low for w in ang_words): + return "anger (70%)", "😡" + if any(w in t_low for w in pos_words): + return "calm (85%)", "😌" + + return "netral (60%)", "😐" + + +def get_message_emotion( + m: Message, emotion_map: dict[uuid.UUID, EmotionAnalysis] | None = None +) -> tuple[str, str]: + """Retrieve emotion tag and emoji for a message from ML emotion map or keyword heuristic.""" + if m.role not in ("user",): + return "", "" + + if emotion_map and m.id in emotion_map: + emo = emotion_map[m.id] + if emo and emo.primary_emotion: + pct = int((float(emo.confidence) if emo.confidence else 0.8) * 100) + emo_lower = emo.primary_emotion.lower() + name_map = { + "fearful": "fear", + "sad": "sadness", + "angry": "anger", + "neutral": "netral", + "happy": "joy", + } + display_name = name_map.get(emo_lower, emo_lower) + emoji_map = { + "happy": "😃", "joy": "😃", "senang": "😃", "bahagia": "😃", + "calm": "😌", "tenang": "😌", "lega": "😌", + "fearful": "😨", "fear": "😨", "cemas": "😨", "takut": "😨", + "sad": "😔", "sadness": "😔", "sedih": "😔", + "angry": "😡", "anger": "😡", "marah": "😡", + "stress": "💥", "stres": "💥", + "neutral": "😐", "netral": "😐", + } + return f"{display_name} ({pct}%)", emoji_map.get(emo_lower, "🌱") + + return analyze_message_emotion_fallback(m.content) + + +def format_conversation( + c: Conversation, emotion_map: dict[uuid.UUID, EmotionAnalysis] | None = None +) -> ConversationDetailResponse: + """Format a conversation entity into a structured ConversationDetailResponse DTO.""" + msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] + last_msg = msgs[-1].content if msgs else "" + formatted_msgs = [] + user_emotions: list[tuple[str, str]] = [] + for m in msgs: + emo_tag, emo_emoji = get_message_emotion(m, emotion_map) + if m.role in ("user",) and emo_tag: + user_emotions.append((emo_tag, emo_emoji)) + formatted_msgs.append( + MessageItemResponse( + id=str(m.id), + sender="eva" if m.role in ("assistant", "luna", "eva") else "user", + text=m.content, + time=format_time_local(m.created_at), + isAudio=m.message_type in (MessageType.VOICE, "voice", "audio"), + emotionTag=emo_tag or None, + emotionEmoji=emo_emoji or None, + ) + ) + + dominant_label = "Tenang & Nyaman" + dominant_emoji = "😌" + if user_emotions: + counts: Counter[str] = Counter() + emoji_by_name: dict[str, str] = {} + for tag, emoji in user_emotions: + name = tag.split()[0].lower() if tag else "netral" + counts[name] += 1 + if emoji: + emoji_by_name[name] = emoji + + most_common_name, _ = counts.most_common(1)[0] + name_map = { + "happy": "Lega & Senang", + "joy": "Lega & Senang", + "calm": "Tenang & Damai", + "fear": "Cemas & Takut", + "fearful": "Cemas & Takut", + "anxiety": "Cemas & Gelisah", + "sadness": "Sedih & Hampa", + "sad": "Sedih & Hampa", + "depression": "Sedih & Tertekan", + "stress": "Stres & Lelah", + "anger": "Marah & Kesal", + "angry": "Marah & Kesal", + "neutral": "Netral & Rileks", + "netral": "Netral & Rileks", + } + dominant_label = name_map.get(most_common_name, most_common_name.capitalize()) + dominant_emoji = emoji_by_name.get(most_common_name, "😌") + + crisis_keywords = ( + "bunuh diri", "capek hidup", "mati saja", "ingin mati", "putus asa", + "menyakiti diri", "gantung diri", "akhiri hidup", "tak sanggup lagi", + "tidak sanggup hidup" + ) + all_user_text = " ".join([m.content.lower() for m in msgs if m.role == "user" and m.content]) + if any(kw in all_user_text for kw in crisis_keywords): + dominant_label = "Krisis / Sangat Tertekan" + dominant_emoji = "🆘" + + end_time = c.last_message_at or (msgs[-1].created_at if msgs else c.started_at) + duration_str = format_duration_str(c.started_at, end_time) + + summary_text = None + if hasattr(c, "summaries") and c.summaries: + summary_text = c.summaries[-1].summary + + return ConversationDetailResponse( + id=str(c.id), + title=c.title or "Sesi Percakapan", + is_title_generating="$skeleton" in (c.title or ""), + lastMessage=last_msg, + lastMessageTime=format_time_local(c.started_at), + duration=duration_str, + dominant_emotion=dominant_label, + dominant_emoji=dominant_emoji, + summary=summary_text, + messages=formatted_msgs, + ) + + +class ConversationService: + """Service to handle conversation sessions, message pagination, emotion aggregation, and chat persistence.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_conversations( + self, user_id: uuid.UUID, page: int = 1, limit: int = 10 + ) -> ConversationListResponse: + """Retrieve paginated conversations for a user with messages and emotion mappings.""" + count_query = ( + select(func.count()) + .select_from(Conversation) + .where(Conversation.user_id == user_id) + ) + total_res = await self.db.execute(count_query) + total_items = total_res.scalar() or 0 + + offset = (page - 1) * limit + query = ( + select(Conversation) + .options( + selectinload(Conversation.messages), + selectinload(Conversation.summaries), + ) + .where(Conversation.user_id == user_id) + .order_by(Conversation.started_at.desc()) + .offset(offset) + .limit(limit) + ) + res = await self.db.execute(query) + convs = res.scalars().all() + + message_ids = [m.id for c in convs for m in c.messages] + emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} + if message_ids: + emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) + emo_res = await self.db.execute(emo_query) + for emo in emo_res.scalars().all(): + emotion_map[emo.message_id] = emo + + formatted_items = [format_conversation(c, emotion_map) for c in convs] + total_pages = max(1, math.ceil(total_items / limit)) if total_items > 0 else 1 + has_more = (offset + len(convs)) < total_items + + return ConversationListResponse( + items=formatted_items, + pagination=ConversationPagination( + page=page, + limit=limit, + total_items=total_items, + total_pages=total_pages, + has_more=has_more, + ), + total=total_items, + page=page, + limit=limit, + has_more=has_more, + ) + + async def get_today_conversations( + self, user_id: uuid.UUID, page: int = 1, limit: int = 10 + ) -> TodayConversationResponse: + """Retrieve today's voice call sessions with emotion breakdown and pagination.""" + today_wib = get_wib_today() + start_utc, end_utc = get_wib_day_range_utc(today_wib) + + count_query = ( + select(func.count()) + .select_from(Conversation) + .where( + Conversation.user_id == user_id, + Conversation.started_at >= start_utc, + Conversation.started_at <= end_utc, + ) + ) + total_res = await self.db.execute(count_query) + total_items = total_res.scalar() or 0 + + offset = (page - 1) * limit + query = ( + select(Conversation) + .options(selectinload(Conversation.messages)) + .where( + Conversation.user_id == user_id, + Conversation.started_at >= start_utc, + Conversation.started_at <= end_utc, + ) + .order_by(Conversation.started_at.desc()) + .offset(offset) + .limit(limit) + ) + res = await self.db.execute(query) + convs = res.scalars().all() + + message_ids = [m.id for c in convs for m in c.messages] + emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} + if message_ids: + emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) + emo_res = await self.db.execute(emo_query) + for emo in emo_res.scalars().all(): + emotion_map[emo.message_id] = emo + + diary_query = select(DiaryEntry).where(DiaryEntry.user_id == user_id, DiaryEntry.entry_date == today_wib) + res_diary = await self.db.execute(diary_query) + today_diary = res_diary.scalar_one_or_none() + + mood_tag = today_diary.mood_tag if today_diary and today_diary.mood_tag else "Tenang & Nyaman 🌿" + ai_insight = today_diary.ai_insight if today_diary and today_diary.ai_insight else "Pengguna merasa didengarkan dan tenang setelah berdialog bersama LUNA." + + items: list[TodayConversationItem] = [] + for c in convs: + msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] + if not msgs: + continue + + time_formatted = format_time_local(c.started_at) + c_emotions = [emotion_map[m.id].emotions for m in msgs if m.id in emotion_map and emotion_map[m.id].emotions] + if c_emotions: + raw_breakdown = MLEmotionDetectorService.aggregate_session_emotions(c_emotions) + breakdown = [ + EmotionBreakdownSimple( + label=b["label"], + emoji=b["emoji"], + percent=float(b["percent"]), + color=b["color"], + ) + for b in raw_breakdown + ] + else: + breakdown = [ + EmotionBreakdownSimple(label="Ketenangan & Kedamaian", emoji="😌", percent=0.70, color="#4ECDC4"), + EmotionBreakdownSimple(label="Bahagia & Puas", emoji="😃", percent=0.20, color="#FFE6A7"), + EmotionBreakdownSimple(label="Tingkat Stres", emoji="😟", percent=0.10, color="#FF8B94"), + ] + + analysis = ConversationAnalysis( + dominant_emotion=mood_tag, + calm_score="85%", + stress_level="Rendah", + empathy_level="Sangat Tinggi", + ai_insight=ai_insight, + emotions_breakdown=breakdown, + ) + + transcript_items: list[TranscriptItem] = [] + for m in msgs: + emo_tag, emo_emoji = get_message_emotion(m, emotion_map) + transcript_items.append( + TranscriptItem( + role=m.role, + content=m.content, + time=format_time_local(m.created_at), + emotionTag=emo_tag if m.role == "user" else None, + emotionEmoji=emo_emoji if m.role == "user" else None, + ) + ) + + items.append( + TodayConversationItem( + id=str(c.id), + title=c.title or "Sesi Panggilan Suara LUNA", + is_title_generating="$skeleton" in (c.title or ""), + date=f"Hari ini, {time_formatted}", + duration=format_duration_str(c.started_at, c.last_message_at), + moodTag=mood_tag, + emotion_analysis=analysis, + transcript=transcript_items, + ) + ) + + return TodayConversationResponse( + items=items, + total=total_items, + page=page, + limit=limit, + has_more=(offset + len(items)) < total_items, + ) + + async def get_today_messages( + self, user_id: uuid.UUID, page: int = 1, limit: int = 10 + ) -> TodayMessagesResponse: + """Retrieve all messages from today's conversations ordered chronologically.""" + today_wib = get_wib_today() + start_utc, end_utc = get_wib_day_range_utc(today_wib) + + conv_query = ( + select(Conversation) + .options(selectinload(Conversation.messages)) + .where( + Conversation.user_id == user_id, + Conversation.started_at >= start_utc, + Conversation.started_at <= end_utc, + ) + .order_by(Conversation.started_at.asc()) + ) + conv_res = await self.db.execute(conv_query) + convs = conv_res.scalars().all() + + message_ids = [m.id for c in convs for m in c.messages] + emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} + if message_ids: + emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) + emo_res = await self.db.execute(emo_query) + for emo in emo_res.scalars().all(): + emotion_map[emo.message_id] = emo + + all_msgs: list[TodayMessageItem] = [] + for c in convs: + sorted_msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] + sess_title = (c.title or "Sesi Panggilan Suara LUNA").replace("$skeleton", "").strip() + for m in sorted_msgs: + if m.role in ("user", "assistant") and m.content and m.content.strip(): + is_user = m.role == "user" + emo_tag, emo_emoji = get_message_emotion(m, emotion_map) + all_msgs.append( + TodayMessageItem( + id=str(m.id), + role=m.role, + isUser=is_user, + content=m.content, + text=m.content, + time=format_time_local(m.created_at or c.started_at), + sessionTitle=sess_title, + emotionTag=emo_tag if is_user else None, + emotionEmoji=emo_emoji if is_user else None, + ) + ) + + total_items = len(all_msgs) + total_pages = (total_items + limit - 1) // limit if total_items > 0 else 1 + offset = (page - 1) * limit + items = all_msgs[offset : offset + limit] + + return TodayMessagesResponse( + items=items, + total_items=total_items, + page=page, + limit=limit, + total_pages=total_pages, + ) + + async def get_conversation_by_id(self, conversation_id: str) -> ConversationDetailResponse: + """Retrieve single conversation detail and transcript.""" + try: + c_uuid = uuid.UUID(conversation_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid conversation ID") + + query = ( + select(Conversation) + .options( + selectinload(Conversation.messages), + selectinload(Conversation.summaries), + ) + .where(Conversation.id == c_uuid) + ) + res = await self.db.execute(query) + conv = res.scalar_one_or_none() + + if not conv: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") + + message_ids = [m.id for m in conv.messages] if conv.messages else [] + emotion_map: dict[uuid.UUID, EmotionAnalysis] = {} + if message_ids: + emo_query = select(EmotionAnalysis).where(EmotionAnalysis.message_id.in_(message_ids)) + emo_res = await self.db.execute(emo_query) + for emo in emo_res.scalars().all(): + emotion_map[emo.message_id] = emo + + return format_conversation(conv, emotion_map) + + async def send_message( + self, conversation_id: str, payload: SendMessageRequest + ) -> SentMessageResponse: + """Send and persist a new user text message in an ongoing conversation session.""" + try: + c_uuid = uuid.UUID(conversation_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid conversation ID") + + query = ( + select(Conversation) + .options(selectinload(Conversation.messages)) + .where(Conversation.id == c_uuid) + ) + res = await self.db.execute(query) + conv = res.scalar_one_or_none() + + if not conv: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found") + + next_seq = len(conv.messages) + 1 + user_msg = Message( + conversation_id=conv.id, + role="user", + content=payload.content, + message_type=MessageType.TEXT, + sequence_number=next_seq, + ) + self.db.add(user_msg) + await self.db.commit() + await self.db.refresh(user_msg) + + if payload.content and payload.content.strip(): + try: + analysis_result = await MLEmotionDetectorService.predict_message_emotion(payload.content) + await MLEmotionDetectorService.save_emotion_to_db(user_msg.id, analysis_result, self.db) + logger.info(f"🧠 [CHAT EMOTION PERSISTED]: Saved emotion for message {user_msg.id}") + except Exception as emo_err: + logger.warning(f"⚠️ [CHAT EMOTION EXCEPTION]: Failed to save emotion analysis: {emo_err}") + + return SentMessageResponse( + id=str(user_msg.id), + sender="user", + text=user_msg.content, + time="Sekarang", + isAudio=False, + ) + + +# Backward-compatible aliases +_format_time_local = format_time_local +_format_duration_str = format_duration_str +_analyze_message_emotion_fallback = analyze_message_emotion_fallback +_get_message_emotion = get_message_emotion +_format_conversation = format_conversation diff --git a/apps/backend/api/app/services/conversation/summary_stream_service.py b/apps/backend/api/app/services/conversation/summary_stream_service.py new file mode 100644 index 0000000..2a4b2c5 --- /dev/null +++ b/apps/backend/api/app/services/conversation/summary_stream_service.py @@ -0,0 +1,167 @@ +from collections.abc import AsyncGenerator +import json +import logging +from typing import Any +import uuid + +from fastapi import HTTPException, status +from fastapi.responses import StreamingResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.session import AsyncSessionLocal +from app.models.conversation import Conversation, ConversationSummary +from packages.ai.factories.llm_factory import LLMFactory +from packages.ai.interfaces.llm import LLMMessage + +logger = logging.getLogger(__name__) + +CRISIS_KEYWORDS = ( + "bunuh diri", "capek hidup", "mati saja", "ingin mati", "putus asa", + "menyakiti diri", "gantung diri", "akhiri hidup", "tak sanggup lagi", + "tidak sanggup hidup" +) + + +class SummaryStreamService: + """Service to generate and stream empathetic counseling summaries via SSE and persist to database.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def stream_summary( + self, conversation_id: uuid.UUID, user_id: uuid.UUID + ) -> StreamingResponse: + """Stream 2-3 sentence empathetic counseling summary token-by-token using SSE.""" + query = ( + select(Conversation) + .options( + selectinload(Conversation.messages), + selectinload(Conversation.summaries), + ) + .where(Conversation.id == conversation_id, Conversation.user_id == user_id) + ) + res = await self.db.execute(query) + conv = res.scalar_one_or_none() + if not conv: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Percakapan tidak ditemukan", + ) + + # 1. If already persisted in DB, yield cached summary immediately + if conv.summaries and conv.summaries[-1].summary: + existing_summary = conv.summaries[-1].summary + + async def cached_stream() -> AsyncGenerator[str, None]: + yield f"data: {json.dumps({'token': existing_summary})}\n\n" + yield f"data: {json.dumps({'done': True, 'summary': existing_summary})}\n\n" + + return StreamingResponse( + cached_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + # 2. Extract user and assistant messages for transcript + msgs = sorted(conv.messages, key=lambda m: m.created_at) if conv.messages else [] + user_assistant_msgs = [ + m for m in msgs if m.role in ("user", "assistant") and m.content and m.content.strip() + ] + + transcript_text = "\n".join([f"{m.role.upper()}: {m.content}" for m in user_assistant_msgs]) + full_lower = transcript_text.lower() + has_crisis = any(kw in full_lower for kw in CRISIS_KEYWORDS) + + if not user_assistant_msgs: + fallback_summary = "Sesi percakapan curhat bersama LUNA berjalan dengan tenang dan aman." + + async def empty_stream() -> AsyncGenerator[str, None]: + yield f"data: {json.dumps({'token': fallback_summary})}\n\n" + yield f"data: {json.dumps({'done': True, 'summary': fallback_summary})}\n\n" + + return StreamingResponse( + empty_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + system_prompt = LLMMessage( + role="system", + content=( + "Anda adalah konselor psikologis profesional LUNA AI. " + "Tugas Anda adalah membuat ringkasan percakapan konseling yang sangat ringkas, padat, dan menenangkan (tepat 2-3 kalimat Bahasa Indonesia).\n" + "Pedoman wajib:\n" + "1. Fokus pada esensi perasaan yang dicurahkan pengguna dan pendampingan reflektif dari LUNA.\n" + "2. Gunakan nada bicara hangat, empatik, tenang, dan suportif.\n" + "3. DILARANG KERAS mengutip mentah kata-kata pengguna dalam tanda kutip.\n" + "4. Jika terdeteksi kondisi krisis berat, berikan narasi suportif yang menekankan ruang aman dan pentingnya pertolongan profesional.\n" + "5. Hasilkan HANYA teks ringkasan langsung tanpa judul, tanpa pembuka/penutup, dan tanpa format markdown." + ), + ) + user_prompt = LLMMessage( + role="user", + content=f"Berikut transkrip percakapan suara:\n\n{transcript_text}\n\nBuat ringkasan konseling 2-3 kalimat:", + ) + + async def generate_stream() -> AsyncGenerator[str, None]: + full_tokens: list[str] = [] + try: + llm_provider = LLMFactory.get_provider() + async for token in llm_provider.stream_response([system_prompt, user_prompt]): + full_tokens.append(token) + yield f"data: {json.dumps({'token': token})}\n\n" + except Exception as e: + logger.error(f"⚠️ [SUMMARY STREAM LLM EXCEPTION]: {e}") + fallback_text = ( + "Pengguna meluangkan waktu untuk mengekspresikan beban emosional yang berat dalam sesi ini. " + "LUNA hadir mendampingi secara suportif, memberikan ruang aman untuk bercerita, " + "dan menegaskan bahwa setiap perasaan berharga serta bantuan selalu tersedia." + if has_crisis + else "Pengguna berbagi refleksi harian dan tantangan perasaan secara terbuka. " + "LUNA memberikan tanggapan yang menenangkan serta ruang aman untuk meredakan ketegangan." + ) + if not full_tokens: + yield f"data: {json.dumps({'token': fallback_text})}\n\n" + full_tokens.append(fallback_text) + + full_summary = "".join(full_tokens).strip() + if full_summary.startswith('"') and full_summary.endswith('"') and len(full_summary) > 2: + full_summary = full_summary[1:-1].strip() + + if full_summary: + try: + async with AsyncSessionLocal() as db_stream: + new_summary = ConversationSummary( + conversation_id=conversation_id, + summary=full_summary, + ) + db_stream.add(new_summary) + await db_stream.commit() + logger.info(f"✅ [SUMMARY STREAM PERSISTED]: Conversation {conversation_id}") + except Exception as db_err: + logger.error(f"⚠️ [FAILED TO PERSIST STREAM SUMMARY]: {db_err}") + + dominant_emotion = "Krisis / Sangat Tertekan" if has_crisis else None + dominant_emoji = "🆘" if has_crisis else None + + yield f"data: {json.dumps({'done': True, 'summary': full_summary, 'dominant_emotion': dominant_emotion, 'dominant_emoji': dominant_emoji})}\n\n" + + return StreamingResponse( + generate_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/apps/backend/api/app/services/conversation_service.py b/apps/backend/api/app/services/conversation_service.py deleted file mode 100644 index 461a7fb..0000000 --- a/apps/backend/api/app/services/conversation_service.py +++ /dev/null @@ -1,188 +0,0 @@ -import uuid -from collections import Counter -from datetime import datetime, timezone -from typing import Any - -from app.core.tz import to_wib -from app.models.conversation import Conversation, Message -from app.models.enums import MessageType -from app.models.safety import EmotionAnalysis - - -def format_time_local(dt: datetime | None) -> str: - """Format datetime into 12-hour local time format (hh:mm AM/PM).""" - if not dt: - return "09:15 AM" - try: - dt_wib = to_wib(dt) - return dt_wib.strftime("%I:%M %p") if dt_wib else "09:15 AM" - except Exception: - return dt.strftime("%I:%M %p") - - -def format_duration_str(started_at: datetime | None, last_message_at: datetime | None) -> str: - """Calculate elapsed session duration and format as MM:SS.""" - if not started_at or not last_message_at: - return "00:00" - try: - t1 = started_at if started_at.tzinfo else started_at.replace(tzinfo=timezone.utc) - t2 = last_message_at if last_message_at.tzinfo else last_message_at.replace(tzinfo=timezone.utc) - diff = int((t2 - t1).total_seconds()) - if diff < 0: - diff = 0 - mins = diff // 60 - secs = diff % 60 - return f"{mins:02d}:{secs:02d}" - except Exception: - return "00:00" - - -def analyze_message_emotion_fallback(text: str) -> tuple[str, str]: - """Determine emotion tag and emoji dynamically from message text keywords.""" - t_low = (text or "").lower() - anx_words = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup", "takutnya", "bingung", "was-was", "tegang"] - dep_words = ["sedih", "nangis", "menangis", "hampa", "sendiri", "kehilangan", "terpuruk", "putus asa", "kecewa", "patah hati", "bunuh diri"] - str_words = ["stres", "capek", "lelah", "beban", "berat", "penat", "pusing", "mumet", "tekanan", "tugas", "deadline", "kerjaan", "letih"] - ang_words = ["marah", "kesal", "jengkel", "benci", "emosi", "sebal", "kesel", "geram", "murka"] - pos_words = ["lega", "senang", "bahagia", "terima kasih", "makasih", "enakan", "tenang", "nyaman", "santai", "alhamdulillah", "syukurlah", "baik", "bersyukur"] - - if any(w in t_low for w in anx_words): - return "fear (78%)", "😨" - if any(w in t_low for w in dep_words): - return "sadness (82%)", "😔" - if any(w in t_low for w in str_words): - return "stress (75%)", "💥" - if any(w in t_low for w in ang_words): - return "anger (70%)", "😡" - if any(w in t_low for w in pos_words): - return "calm (85%)", "😌" - - return "netral (60%)", "😐" - - -def get_message_emotion( - m: Message, emotion_map: dict[uuid.UUID, EmotionAnalysis] | None = None -) -> tuple[str, str]: - """Retrieve emotion tag and emoji for a message from ML emotion map or keyword heuristic.""" - if m.role not in ("user",): - return "", "" - - if emotion_map and m.id in emotion_map: - emo = emotion_map[m.id] - if emo and emo.primary_emotion: - pct = int((float(emo.confidence) if emo.confidence else 0.8) * 100) - emo_lower = emo.primary_emotion.lower() - name_map = { - "fearful": "fear", - "sad": "sadness", - "angry": "anger", - "neutral": "netral", - "happy": "joy", - } - display_name = name_map.get(emo_lower, emo_lower) - emoji_map = { - "happy": "😃", "joy": "😃", "senang": "😃", "bahagia": "😃", - "calm": "😌", "tenang": "😌", "lega": "😌", - "fearful": "😨", "fear": "😨", "cemas": "😨", "takut": "😨", - "sad": "😔", "sadness": "😔", "sedih": "😔", - "angry": "😡", "anger": "😡", "marah": "😡", - "stress": "💥", "stres": "💥", - "neutral": "😐", "netral": "😐", - } - return f"{display_name} ({pct}%)", emoji_map.get(emo_lower, "🌱") - - return analyze_message_emotion_fallback(m.content) - - -def format_conversation( - c: Conversation, emotion_map: dict[uuid.UUID, EmotionAnalysis] | None = None -) -> dict[str, Any]: - """Format a conversation entity into a detailed API response dictionary.""" - msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] - last_msg = msgs[-1].content if msgs else "" - formatted_msgs = [] - user_emotions: list[tuple[str, str]] = [] - for m in msgs: - emo_tag, emo_emoji = get_message_emotion(m, emotion_map) - if m.role in ("user",) and emo_tag: - user_emotions.append((emo_tag, emo_emoji)) - formatted_msgs.append({ - "id": str(m.id), - "sender": "eva" if m.role in ("assistant", "luna", "eva") else "user", - "text": m.content, - "time": format_time_local(m.created_at), - "isAudio": m.message_type in (MessageType.VOICE, "voice", "audio"), - "emotionTag": emo_tag, - "emotionEmoji": emo_emoji, - }) - - dominant_label = "Tenang & Nyaman" - dominant_emoji = "😌" - if user_emotions: - counts: Counter[str] = Counter() - emoji_by_name: dict[str, str] = {} - for tag, emoji in user_emotions: - name = tag.split()[0].lower() if tag else "netral" - counts[name] += 1 - if emoji: - emoji_by_name[name] = emoji - - most_common_name, _ = counts.most_common(1)[0] - name_map = { - "happy": "Lega & Senang", - "joy": "Lega & Senang", - "calm": "Tenang & Damai", - "fear": "Cemas & Takut", - "fearful": "Cemas & Takut", - "anxiety": "Cemas & Gelisah", - "sadness": "Sedih & Hampa", - "sad": "Sedih & Hampa", - "depression": "Sedih & Tertekan", - "stress": "Stres & Lelah", - "anger": "Marah & Kesal", - "angry": "Marah & Kesal", - "neutral": "Netral & Rileks", - "netral": "Netral & Rileks", - } - dominant_label = name_map.get(most_common_name, most_common_name.capitalize()) - dominant_emoji = emoji_by_name.get(most_common_name, "😌") - - # Safety/Crisis Override: Jangan pernah kategorikan krisis atau niat menyakiti diri sebagai Netral & Rileks - crisis_keywords = ( - "bunuh diri", "capek hidup", "mati saja", "ingin mati", "putus asa", - "menyakiti diri", "gantung diri", "akhiri hidup", "tak sanggup lagi", - "tidak sanggup hidup" - ) - all_user_text = " ".join([m.content.lower() for m in msgs if m.role == "user" and m.content]) - if any(kw in all_user_text for kw in crisis_keywords): - dominant_label = "Krisis / Sangat Tertekan" - dominant_emoji = "🆘" - - end_time = c.last_message_at or (msgs[-1].created_at if msgs else c.started_at) - duration_str = format_duration_str(c.started_at, end_time) - - # Return summary from DB if exists; otherwise None to trigger client-side skeleton & stream - summary_text = None - if hasattr(c, "summaries") and c.summaries: - summary_text = c.summaries[-1].summary - - return { - "id": str(c.id), - "title": c.title or "Sesi Percakapan", - "is_title_generating": "$skeleton" in (c.title or ""), - "lastMessage": last_msg, - "lastMessageTime": format_time_local(c.started_at), - "duration": duration_str, - "dominant_emotion": dominant_label, - "dominant_emoji": dominant_emoji, - "summary": summary_text, - "messages": formatted_msgs, - } - - -# Backwards-compatible aliases -_format_time_local = format_time_local -_format_duration_str = format_duration_str -_analyze_message_emotion_fallback = analyze_message_emotion_fallback -_get_message_emotion = get_message_emotion -_format_conversation = format_conversation diff --git a/apps/backend/api/app/services/dass/__init__.py b/apps/backend/api/app/services/dass/__init__.py new file mode 100644 index 0000000..0f41e9d --- /dev/null +++ b/apps/backend/api/app/services/dass/__init__.py @@ -0,0 +1,3 @@ +from app.services.dass.dass_service import DASSService, OFFICIAL_DASS21_ITEMS + +__all__ = ["DASSService", "OFFICIAL_DASS21_ITEMS"] diff --git a/apps/backend/api/app/services/dass_service.py b/apps/backend/api/app/services/dass/dass_service.py similarity index 78% rename from apps/backend/api/app/services/dass_service.py rename to apps/backend/api/app/services/dass/dass_service.py index c0f9a3d..95207d7 100644 --- a/apps/backend/api/app/services/dass_service.py +++ b/apps/backend/api/app/services/dass/dass_service.py @@ -1,12 +1,16 @@ import logging -import uuid from datetime import date from typing import Any +import uuid from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from sqlalchemy.orm.attributes import flag_modified +from ai.services.dass_extraction_service import DASSExtractionService +from app.core.tz import get_wib_day_range_utc, get_wib_today +from app.models.conversation import Conversation from app.models.dass import DASSAssessment from app.schemas.dass import ( DASSAssessmentResponse, @@ -42,6 +46,11 @@ class DASSService: + """Service to manage DASS-21 psychological assessments, automated AI extraction, and score calculations.""" + + def __init__(self, db: AsyncSession): + self.db = db + @staticmethod def generate_default_items() -> list[dict[str, Any]]: """Menghasilkan template 21 butir resmi dengan nilai default 0.""" @@ -62,7 +71,6 @@ def generate_default_items() -> list[dict[str, Any]]: @staticmethod def calculate_scores(items: list[dict[str, Any]]) -> dict[str, Any]: """Menghitung total skor per subskala (dikali 2 sesuai norma DASS-21 Indonesia) - dan mengklasifikasikan ke dalam kategori keparahan. """ dep_raw = sum(int(it.get("score", 0)) for it in items if it.get("scale") == "depression") @@ -118,29 +126,23 @@ def calculate_scores(items: list[dict[str, Any]]) -> dict[str, Any]: "stress_severity": str_sev, } - @staticmethod - async def get_today_assessment( - user_id: uuid.UUID, - today_date: date, - db: AsyncSession, - ) -> DASSAssessmentResponse: + async def get_today_assessment(self, user_id: uuid.UUID) -> DASSAssessmentResponse: """Mengambil asesmen DASS hari ini. Jika belum ada di database, - mengembalikan form default 21 butir (skor 0) agar mobile tetap bisa menampilkan form. """ + today_date = get_wib_today() stmt = select(DASSAssessment).where( DASSAssessment.user_id == user_id, DASSAssessment.assessed_date == today_date, ) - res = await db.execute(stmt) + res = await self.db.execute(stmt) record = res.scalars().first() if record: return DASSAssessmentResponse.model_validate(record) - # Kembalikan template belum dinilai - default_items = DASSService.generate_default_items() - scores = DASSService.calculate_scores(default_items) + default_items = self.generate_default_items() + scores = self.calculate_scores(default_items) return DASSAssessmentResponse( id=None, @@ -158,37 +160,125 @@ async def get_today_assessment( items=[DASSItemSchema(**it) for it in default_items], ) - @staticmethod + async def update_user_assessment( + self, + user_id: uuid.UUID, + updates: list[DASSItemUpdate], + assessment_id: uuid.UUID | None = None, + ) -> DASSAssessmentResponse: + """Memperbarui skor butir yang diedit oleh pengguna, menghitung ulang skor subskala, + dan menandai record sebagai 'verified'. + """ + today_date = get_wib_today() + stmt = select(DASSAssessment).where( + DASSAssessment.user_id == user_id, + DASSAssessment.assessed_date == today_date, + ) + res = await self.db.execute(stmt) + record = res.scalars().first() + + if not record: + base_items = self.generate_default_items() + record = DASSAssessment( + user_id=user_id, + assessed_date=today_date, + verified_by_user=True, + status="verified", + items=base_items, + ) + self.db.add(record) + + current_items = list(record.items) if record.items else self.generate_default_items() + item_dict_map = {it["item_id"]: it for it in current_items} + + for up in updates: + if up.item_id in item_dict_map: + item_dict_map[up.item_id]["score"] = up.score + item_dict_map[up.item_id]["is_user_edited"] = True + + new_items = list(item_dict_map.values()) + scores = self.calculate_scores(new_items) + + record.items = new_items + flag_modified(record, "items") + record.depression_score = scores["depression_score"] + record.anxiety_score = scores["anxiety_score"] + record.stress_score = scores["stress_score"] + record.depression_severity = scores["depression_severity"] + record.anxiety_severity = scores["anxiety_severity"] + record.stress_severity = scores["stress_severity"] + record.verified_by_user = True + record.status = "verified" + + await self.db.commit() + await self.db.refresh(record) + return DASSAssessmentResponse.model_validate(record) + + async def extract_today_assessment(self, user_id: uuid.UUID) -> DASSAssessmentResponse: + """Trigger AI extraction of 21 DASS items from today's conversation transcripts.""" + today_wib = get_wib_today() + start_utc, end_utc = get_wib_day_range_utc(today_wib) + + conv_stmt = ( + select(Conversation) + .where( + Conversation.user_id == user_id, + Conversation.created_at >= start_utc, + Conversation.created_at <= end_utc, + ) + .options(selectinload(Conversation.messages)) + ) + conv_res = await self.db.execute(conv_stmt) + conversations = conv_res.scalars().all() + + formatted_msgs = [] + latest_conv_id = None + for c in conversations: + latest_conv_id = c.id + for m in sorted(c.messages, key=lambda x: x.created_at): + if m.content and m.content.strip(): + formatted_msgs.append(f"{m.role.upper()}: {m.content}") + + transcript = "\n".join(formatted_msgs) + extractor = DASSExtractionService() + extracted_items = await extractor.extract_from_transcript(transcript) + + saved_record = await self.save_or_update_extracted( + user_id=user_id, + assessed_date=today_wib, + extracted_items=extracted_items, + conversation_id=latest_conv_id, + ) + return DASSAssessmentResponse.model_validate(saved_record) + async def save_or_update_extracted( + self, user_id: uuid.UUID, assessed_date: date, extracted_items: list[dict[str, Any]], conversation_id: uuid.UUID | None, - db: AsyncSession, ) -> DASSAssessment: """Menyimpan atau memperbarui hasil ekstraksi AI ke tabel dass_assessments.""" - # Gabungkan dengan template lengkap 21 butir jika extracted_items belum lengkap item_map = {it["item_id"]: it for it in extracted_items} complete_items = [] - for default_it in DASSService.generate_default_items(): + for default_it in self.generate_default_items(): i_id = default_it["item_id"] if i_id in item_map: complete_items.append(item_map[i_id]) else: complete_items.append(default_it) - scores = DASSService.calculate_scores(complete_items) + scores = self.calculate_scores(complete_items) stmt = select(DASSAssessment).where( DASSAssessment.user_id == user_id, DASSAssessment.assessed_date == assessed_date, ) - res = await db.execute(stmt) + res = await self.db.execute(stmt) record = res.scalars().first() if record: - # Jika user sudah memverifikasi manual, jangan ditimpa oleh auto_extracted if not record.verified_by_user: record.depression_score = scores["depression_score"] record.anxiety_score = scores["anxiety_score"] @@ -215,64 +305,8 @@ async def save_or_update_extracted( status="auto_extracted", items=complete_items, ) - db.add(record) + self.db.add(record) - await db.commit() - await db.refresh(record) + await self.db.commit() + await self.db.refresh(record) return record - - @staticmethod - async def update_user_assessment( - user_id: uuid.UUID, - today_date: date, - updates: list[DASSItemUpdate], - db: AsyncSession, - ) -> DASSAssessmentResponse: - """Memperbarui skor butir yang diedit oleh pengguna, menghitung ulang skor subskala, - - dan menandai record sebagai 'verified'. - """ - stmt = select(DASSAssessment).where( - DASSAssessment.user_id == user_id, - DASSAssessment.assessed_date == today_date, - ) - res = await db.execute(stmt) - record = res.scalars().first() - - if not record: - # Jika belum ada record tersimpan di DB, buat baru dari default items - base_items = DASSService.generate_default_items() - record = DASSAssessment( - user_id=user_id, - assessed_date=today_date, - verified_by_user=True, - status="verified", - items=base_items, - ) - db.add(record) - - current_items = list(record.items) if record.items else DASSService.generate_default_items() - item_dict_map = {it["item_id"]: it for it in current_items} - - for up in updates: - if up.item_id in item_dict_map: - item_dict_map[up.item_id]["score"] = up.score - item_dict_map[up.item_id]["is_user_edited"] = True - - new_items = list(item_dict_map.values()) - scores = DASSService.calculate_scores(new_items) - - record.items = new_items - flag_modified(record, "items") - record.depression_score = scores["depression_score"] - record.anxiety_score = scores["anxiety_score"] - record.stress_score = scores["stress_score"] - record.depression_severity = scores["depression_severity"] - record.anxiety_severity = scores["anxiety_severity"] - record.stress_severity = scores["stress_severity"] - record.verified_by_user = True - record.status = "verified" - - await db.commit() - await db.refresh(record) - return DASSAssessmentResponse.model_validate(record) diff --git a/apps/backend/api/app/services/diary/__init__.py b/apps/backend/api/app/services/diary/__init__.py new file mode 100644 index 0000000..1c62d52 --- /dev/null +++ b/apps/backend/api/app/services/diary/__init__.py @@ -0,0 +1,4 @@ +from app.services.diary.diary_generator import DiaryGeneratorService +from app.services.diary.diary_service import DiaryService + +__all__ = ["DiaryService", "DiaryGeneratorService"] diff --git a/apps/backend/api/app/services/diary_generator.py b/apps/backend/api/app/services/diary/diary_generator.py similarity index 98% rename from apps/backend/api/app/services/diary_generator.py rename to apps/backend/api/app/services/diary/diary_generator.py index d8a3909..fd8ef39 100644 --- a/apps/backend/api/app/services/diary_generator.py +++ b/apps/backend/api/app/services/diary/diary_generator.py @@ -1,17 +1,16 @@ import json import logging -from datetime import date, datetime, timezone +from datetime import date from typing import Any import uuid -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.tz import get_wib_day_range_utc, get_wib_now, get_wib_today, to_wib from app.models.conversation import Conversation from app.models.diary import DiaryEntry -from app.models.user import User from packages.ai.factories.llm_factory import LLMFactory from packages.ai.interfaces.llm import LLMMessage diff --git a/apps/backend/api/app/services/diary/diary_service.py b/apps/backend/api/app/services/diary/diary_service.py new file mode 100644 index 0000000..aaf0f0c --- /dev/null +++ b/apps/backend/api/app/services/diary/diary_service.py @@ -0,0 +1,358 @@ +from datetime import date +import logging +from typing import Any +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.tz import get_wib_day_range_utc, get_wib_today, to_wib +from app.models.conversation import Conversation +from app.models.diary import DiaryEntry +from app.schemas.diary import ( + CreateDiaryRequest, + DeleteDiaryResponse, + DiaryEntryResponse, + DiaryRiskWarning, + DiarySessionItem, + EmotionBreakdownItem, + TranscriptItem, +) +from app.services.diary.diary_generator import DiaryGeneratorService + +logger = logging.getLogger(__name__) + + +class DiaryService: + """Service handling diary CRUD, emotion analysis breakdown, and session transcript aggregation.""" + + def __init__(self, db: AsyncSession): + self.db = db + + @staticmethod + def _analyze_message_emotion(text: str, is_user: bool) -> tuple[str, str]: + """Dynamically determine emotion tag and emoji from individual message content.""" + if not is_user: + return "empathy", "💙" + + t_low = text.lower() + anx_words = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup", "takutnya", "bingung"] + dep_words = ["sedih", "nangis", "menangis", "hampa", "sendiri", "kehilangan", "terpuruk", "putus asa", "kecewa", "patah hati", "bunuh diri"] + str_words = ["stres", "capek", "lelah", "beban", "berat", "penat", "pusing", "mumet", "tekanan", "tugas", "deadline", "kerjaan"] + ang_words = ["marah", "kesal", "jengkel", "benci", "emosi", "sebal", "kesel"] + pos_words = ["lega", "senang", "bahagia", "terima kasih", "makasih", "enakan", "tenang", "nyaman", "santai", "alhamdulillah", "syukurlah", "baik"] + + if any(w in t_low for w in anx_words): + return "fear (78%)", "😨" + if any(w in t_low for w in dep_words): + return "sadness (82%)", "😔" + if any(w in t_low for w in str_words): + return "stress (75%)", "💥" + if any(w in t_low for w in ang_words): + return "anger (70%)", "😡" + if any(w in t_low for w in pos_words): + return "calm (85%)", "😌" + + return "netral (60%)", "😐" + + @staticmethod + def _calculate_session_emotions( + transcripts: list[TranscriptItem], + session_title: str, + default_breakdown: list[EmotionBreakdownItem], + ) -> list[EmotionBreakdownItem]: + combined_text = (session_title + " " + " ".join(t.text for t in transcripts if t.isUser)).lower() + + anx_keywords = ["cemas", "takut", "nervous", "panggung", "khawatir", "panik", "deg-degan", "gugup"] + dep_keywords = ["bunuh diri", "depresi", "sedih", "sendiri", "kehilangan", "hampa", "nangis", "menangis", "terpuruk"] + str_keywords = ["stres", "capek", "lelah", "beban", "berat", "penat", "tugas", "kerja"] + pos_keywords = ["lega", "senang", "terima kasih", "enakan", "tenang", "nyaman", "tidur", "santai", "baik"] + + anx_count = sum(1 for kw in anx_keywords if kw in combined_text) + dep_count = sum(1 for kw in dep_keywords if kw in combined_text) + str_count = sum(1 for kw in str_keywords if kw in combined_text) + pos_count = sum(1 for kw in pos_keywords if kw in combined_text) + + if anx_count == 0 and dep_count == 0 and str_count == 0 and pos_count == 0: + return default_breakdown + + raw = [ + {"name": "fear", "label": "Takut / Gelisah", "emoji": "😨", "percent": max(0.04, anx_count * 0.25), "color": "#6C63FF"}, + {"name": "sadness", "label": "Sedih / Haru", "emoji": "😔", "percent": max(0.04, dep_count * 0.25), "color": "#8B93FF"}, + {"name": "netral", "label": "Netral", "emoji": "😐", "percent": 0.08, "color": "#A7E6FF"}, + {"name": "stress", "label": "Stres / Tertekan", "emoji": "💥", "percent": max(0.04, str_count * 0.20), "color": "#FF8A80"}, + {"name": "happy", "label": "Bahagia", "emoji": "😃", "percent": max(0.03, pos_count * 0.15), "color": "#FFE6A7"}, + {"name": "surprise", "label": "Terkejut", "emoji": "😲", "percent": 0.02, "color": "#C3B8FF"}, + {"name": "anger", "label": "Marah", "emoji": "😡", "percent": 0.01, "color": "#FFB6C1"}, + ] + tot = sum(item["percent"] for item in raw) + return [ + EmotionBreakdownItem( + name=item["name"], + label=item["label"], + emoji=item["emoji"], + percent=round(item["percent"] / tot, 2), + color=item["color"], + ) + for item in raw + ] + + @classmethod + def format_diary_entry(cls, d: DiaryEntry | None) -> DiaryEntryResponse: + """Format a DiaryEntry SQLAlchemy model into a structured DiaryEntryResponse DTO.""" + if not d: + return DiaryEntryResponse() + + raw_events = d.important_events if isinstance(d.important_events, list) else [] + events = raw_events if raw_events else ["Sesi refleksi harian tercatat dalam sistem LUNA."] + + scores = d.mental_health_scores or {} + dep = float(scores.get("depression", 0.25)) + anx = float(scores.get("anxiety", 0.45)) + strs = float(scores.get("stress", 0.15)) + + raw_breakdown = [ + {"name": "fear", "label": "Takut / Gelisah", "emoji": "😨", "percent": max(0.05, anx), "color": "#6C63FF"}, + {"name": "sadness", "label": "Sedih / Haru", "emoji": "😔", "percent": max(0.05, dep), "color": "#8B93FF"}, + {"name": "netral", "label": "Netral", "emoji": "😐", "percent": 0.15, "color": "#A7E6FF"}, + {"name": "stress", "label": "Stres / Tertekan", "emoji": "💥", "percent": max(0.05, strs), "color": "#FF8A80"}, + {"name": "happy", "label": "Bahagia", "emoji": "😃", "percent": 0.10, "color": "#FFE6A7"}, + {"name": "surprise", "label": "Terkejut", "emoji": "😲", "percent": 0.03, "color": "#C3B8FF"}, + {"name": "anger", "label": "Marah", "emoji": "😡", "percent": 0.02, "color": "#FFB6C1"}, + ] + tot = sum(item["percent"] for item in raw_breakdown) + emotions_breakdown = [ + EmotionBreakdownItem( + name=item["name"], + label=item["label"], + emoji=item["emoji"], + percent=round(item["percent"] / tot, 2), + color=item["color"], + ) + for item in raw_breakdown + ] + + mood_tag = d.mood_tag or "Netral" + has_risk = any(kw in mood_tag for kw in ("Darurat", "Stres", "Cemas", "Sedih")) + risk_warning = ( + DiaryRiskWarning( + detected=True, + type="High Risk / Krisis", + title="PERINGATAN KRISIS EMOSIONAL", + level="RISIKO TINGGI", + message="Sistem LUNA mendeteksi akumulasi indikasi krisis emosional tinggi dan stres berat pada percakapan hari ini. Protokol keselamatan aktif untuk rujukan darurat 119 ext 8.", + ) + if has_risk + else None + ) + + return DiaryEntryResponse( + id=str(d.id), + title=d.title or "Refleksi Harian LUNA", + date=d.entry_date.strftime("%d %B %Y") if isinstance(d.entry_date, date) else str(d.entry_date), + sessionCount=0, + lastSessionTime="-", + moodTag=mood_tag, + moodEmoji=d.mood_emoji or "😌", + summary=d.summary or "Catatan harian perkembangan emosional bersama LUNA.", + riskWarning=risk_warning, + aiInsight=d.ai_insight or "Analisis AI menunjukkan kondisi stabil.", + importantEvents=events, + emotionalReflection=d.emotional_reflection or "Merasa tenang setelah refleksi.", + mentalHealthScores=scores, + emotionsBreakdown=emotions_breakdown, + sessions=[], + ) + + async def _attach_sessions( + self, formatted: DiaryEntryResponse, entry: DiaryEntry + ) -> DiaryEntryResponse: + """Attach real conversation transcripts and breakdown for the diary's date.""" + if not isinstance(entry.entry_date, date): + return formatted + + start_utc, end_utc = get_wib_day_range_utc(entry.entry_date) + conv_query = ( + select(Conversation) + .options(selectinload(Conversation.messages)) + .where( + Conversation.user_id == entry.user_id, + Conversation.started_at >= start_utc, + Conversation.started_at <= end_utc, + ) + .order_by(Conversation.started_at.asc()) + ) + conv_res = await self.db.execute(conv_query) + convs = conv_res.scalars().all() + + sessions_data: list[DiarySessionItem] = [] + for c in convs: + sorted_msgs = sorted(c.messages, key=lambda m: m.sequence_number) if c.messages else [] + sess_transcripts: list[TranscriptItem] = [] + for m in sorted_msgs: + if m.role in ("user", "assistant") and m.content: + msg_t = to_wib(m.created_at) + emo_tag, emo_emoji = self._analyze_message_emotion(m.content, m.role == "user") + sess_transcripts.append( + TranscriptItem( + isUser=m.role == "user", + time=msg_t.strftime("%H:%M") if msg_t else "09:00", + text=m.content, + emotionTag=emo_tag, + emotionEmoji=emo_emoji, + ) + ) + + if not sess_transcripts: + continue + + c_t = to_wib(c.started_at) + time_str = c_t.strftime("%H:%M") if c_t else "09:00" + title = (c.title or "").replace("$skeleton", "").strip() + if not title: + title = f"Sesi #{len(sessions_data) + 1} Percakapan Suara" + + sess_emotions = self._calculate_session_emotions( + sess_transcripts, + title, + formatted.emotionsBreakdown, + ) + + sessions_data.append( + DiarySessionItem( + id=str(c.id), + title=title, + time=time_str, + moodTag=entry.mood_tag or "Netral", + moodEmoji=entry.mood_emoji or "😌", + emotionsBreakdown=sess_emotions, + transcripts=sess_transcripts, + ) + ) + + formatted.sessions = sessions_data + formatted.sessionCount = len(sessions_data) + formatted.lastSessionTime = sessions_data[-1].time if sessions_data else "-" + return formatted + + async def get_diaries( + self, user_id: uuid.UUID, mood: str | None = None, search: str | None = None + ) -> list[DiaryEntryResponse]: + """Fetch all diary entries for a user, optionally filtered by mood tag or search term.""" + query = select(DiaryEntry).where(DiaryEntry.user_id == user_id).order_by(DiaryEntry.entry_date.desc()) + res = await self.db.execute(query) + entries = res.scalars().all() + + formatted_list: list[DiaryEntryResponse] = [] + for e in entries: + if e is not None: + f = self.format_diary_entry(e) + f = await self._attach_sessions(f, e) + formatted_list.append(f) + + if mood and mood != "Semua": + clean_mood = mood.lower() + formatted_list = [f for f in formatted_list if clean_mood in (f.moodTag or "").lower()] + + if search: + clean_q = search.lower() + formatted_list = [ + f for f in formatted_list + if clean_q in (f.title or "").lower() or clean_q in (f.summary or "").lower() + ] + + return formatted_list + + async def get_today_diary(self, user_id: uuid.UUID) -> DiaryEntryResponse: + """Fetch today's diary entry, generating a new one via LLM if not yet present.""" + today = get_wib_today() + query = select(DiaryEntry).where(DiaryEntry.user_id == user_id, DiaryEntry.entry_date == today) + res = await self.db.execute(query) + entry = res.scalar_one_or_none() + + if not entry: + entry = await DiaryGeneratorService.generate_today_diary(user_id, self.db) + + if not entry: + return DiaryEntryResponse(id=None, message="Belum ada jurnal untuk hari ini.") + + formatted = self.format_diary_entry(entry) + return await self._attach_sessions(formatted, entry) + + async def get_diary_by_id(self, user_id: uuid.UUID, diary_id: str) -> DiaryEntryResponse: + """Fetch single diary entry by ID verifying user ownership.""" + try: + d_uuid = uuid.UUID(diary_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid diary ID") + + query = select(DiaryEntry).where( + DiaryEntry.id == d_uuid, + DiaryEntry.user_id == user_id, + ) + res = await self.db.execute(query) + entry = res.scalar_one_or_none() + + if not entry: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Diary entry not found") + + formatted = self.format_diary_entry(entry) + return await self._attach_sessions(formatted, entry) + + async def generate_today_diary(self, user_id: uuid.UUID) -> DiaryEntryResponse: + """Force manual generation or re-synthesis of today's diary.""" + entry = await DiaryGeneratorService.generate_today_diary(user_id, self.db) + return self.format_diary_entry(entry) + + async def create_diary(self, user_id: uuid.UUID, payload: CreateDiaryRequest) -> DiaryEntryResponse: + """Create or append today's custom diary entry reflection.""" + today = get_wib_today() + + query = select(DiaryEntry).where(DiaryEntry.user_id == user_id, DiaryEntry.entry_date == today) + res = await self.db.execute(query) + entry = res.scalar_one_or_none() + + if entry: + entry.summary += f"\n- {payload.content}" + entry.content += f"\n- {payload.content}" + if payload.mood_tag: + entry.mood_tag = payload.mood_tag + else: + entry = DiaryEntry( + user_id=user_id, + entry_date=today, + title="Refleksi Catatan Baru", + summary=payload.content, + content=payload.content, + mood_tag=payload.mood_tag or "Netral", + mood_emoji="😌", + ai_insight="Catatan refleksi berhasil disimpan.", + emotional_reflection="Merasa lega setelah menulis catatan.", + important_events=[f"[Sesi #1] {payload.content[:50]}"], + ) + self.db.add(entry) + + await self.db.commit() + await self.db.refresh(entry) + + return self.format_diary_entry(entry) + + async def delete_diary(self, diary_id: str) -> DeleteDiaryResponse: + """Delete a diary entry by ID.""" + try: + d_uuid = uuid.UUID(diary_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid diary ID") + + query = select(DiaryEntry).where(DiaryEntry.id == d_uuid) + res = await self.db.execute(query) + entry = res.scalar_one_or_none() + + if not entry: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Diary entry not found") + + await self.db.delete(entry) + await self.db.commit() + return DeleteDiaryResponse() diff --git a/apps/backend/api/app/services/emotion/__init__.py b/apps/backend/api/app/services/emotion/__init__.py new file mode 100644 index 0000000..9b2746e --- /dev/null +++ b/apps/backend/api/app/services/emotion/__init__.py @@ -0,0 +1,4 @@ +from app.services.emotion.analyzer import EmotionAnalyzerService +from app.services.emotion.ml_detector import MLEmotionDetectorService + +__all__ = ["MLEmotionDetectorService", "EmotionAnalyzerService"] diff --git a/apps/backend/api/app/services/emotion_analyzer.py b/apps/backend/api/app/services/emotion/analyzer.py similarity index 100% rename from apps/backend/api/app/services/emotion_analyzer.py rename to apps/backend/api/app/services/emotion/analyzer.py diff --git a/apps/backend/api/app/services/ml_emotion_detector.py b/apps/backend/api/app/services/emotion/ml_detector.py similarity index 100% rename from apps/backend/api/app/services/ml_emotion_detector.py rename to apps/backend/api/app/services/emotion/ml_detector.py diff --git a/apps/backend/api/app/services/recommendation/__init__.py b/apps/backend/api/app/services/recommendation/__init__.py new file mode 100644 index 0000000..1553067 --- /dev/null +++ b/apps/backend/api/app/services/recommendation/__init__.py @@ -0,0 +1,3 @@ +from app.services.recommendation.recommendation_service import RecommendationService + +__all__ = ["RecommendationService"] diff --git a/apps/backend/api/app/services/recommendation/recommendation_service.py b/apps/backend/api/app/services/recommendation/recommendation_service.py new file mode 100644 index 0000000..8ea8645 --- /dev/null +++ b/apps/backend/api/app/services/recommendation/recommendation_service.py @@ -0,0 +1,398 @@ +import logging +from typing import Any +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.tz import get_wib_today +from app.models.coping_activity import CopingActivity, UserActivityCompletion +from app.models.dass import DASSAssessment +from app.models.diary import DiaryEntry +from app.models.enums import SafetyEventStatus +from app.models.recommendation import RecommendationItem +from app.models.safety import SafetyEvent +from app.models.user import User +from app.schemas.recommendation import ( + CompleteActivityResponse, + CopingActivityResponse, + TodayRecommendationResponse, +) + +logger = logging.getLogger(__name__) + + +class RecommendationService: + """Service to evaluate mental health conditions (DASS, Safety Crisis, Diary) and recommend tailored coping activities.""" + + def __init__(self, db: AsyncSession): + self.db = db + + @staticmethod + def _map_act(act: CopingActivity, completed_ids: set[uuid.UUID]) -> CopingActivityResponse: + return CopingActivityResponse( + id=str(act.id), + title=act.title, + category=act.category, + targetCondition=act.target_condition, + duration=act.duration, + difficulty=act.difficulty, + level=act.difficulty, + description=act.description, + instructions=act.instructions or [], + rationale=act.rationale or "", + iconName=act.icon_name or "spa", + isCompleted=act.id in completed_ids, + ) + + async def get_today_recommendation(self, user_id: uuid.UUID) -> TodayRecommendationResponse: + """Evaluate today's condition and return recommended coping activities and crisis guidance.""" + today_wib = get_wib_today() + + # 1. Check open Crisis / Suicide Risk + is_crisis = False + crisis_hotline = "Kemenkes 119 ext 8 / LISA 0811-3855-472" + crisis_guidance = ( + "Layanan konseling mandiri AI dibatasi saat terindikasi krisis emosional tinggi. " + "Keselamatanmu adalah hal yang paling berharga. Tolong segera hubungi tenaga profesional atau orang terdekat sekarang juga." + ) + + safety_query = ( + select(SafetyEvent) + .where( + SafetyEvent.user_id == user_id, + SafetyEvent.status == SafetyEventStatus.OPEN, + SafetyEvent.severity.in_(["high", "critical"]), + ) + .order_by(desc(SafetyEvent.detected_at)) + .limit(1) + ) + res_safety = await self.db.execute(safety_query) + open_safety_event = res_safety.scalars().first() + + # 2. Fetch today's or most recent DASS-21 assessment + dass_query = ( + select(DASSAssessment) + .where(DASSAssessment.user_id == user_id) + .order_by(desc(DASSAssessment.assessed_date)) + .limit(1) + ) + res_dass = await self.db.execute(dass_query) + latest_dass = res_dass.scalars().first() + + # 3. Fetch today's or recent diary entry + diary_query = ( + select(DiaryEntry) + .where(DiaryEntry.user_id == user_id, DiaryEntry.entry_date == today_wib) + .order_by(desc(DiaryEntry.created_at)) + ) + res_diary = await self.db.execute(diary_query) + today_diary = res_diary.scalars().first() + + if not today_diary: + recent_query = ( + select(DiaryEntry) + .where(DiaryEntry.user_id == user_id) + .order_by(desc(DiaryEntry.entry_date)) + .limit(1) + ) + res_recent = await self.db.execute(recent_query) + today_diary = res_recent.scalars().first() + + # Check for suicide / self-harm indication in DASS item 21 + suicide_dass_detected = False + if latest_dass and latest_dass.items: + for it in latest_dass.items: + if it.get("item_id") == 21 and int(it.get("score", 0)) >= 2: + suicide_dass_detected = True + break + + if open_safety_event or suicide_dass_detected: + is_crisis = True + dominant_condition = "crisis" + severity_level = "Krisis / Darurat" + condition_label = "Protokol Krisis & Bantuan Darurat" + condition_reason = ( + "Terdeteksi indikasi krisis emosional atau pemikiran menyakiti diri. " + "Sesuai pedoman klinis WHO (Knowledge K013), prioritas utama saat ini adalah keselamatan dan pendampingan darurat." + ) + else: + dominant_condition = "general" + severity_level = "Normal" + condition_label = "Fokus: Relaksasi & Perawatan Diri" + condition_reason = "Menjaga ritme emosi dan ketenangan pikiran harian dengan latihan mandiri sederhana." + + # Prioritas 1: Asesmen DASS-21 + if latest_dass and (latest_dass.stress_score > 0 or latest_dass.anxiety_score > 0 or latest_dass.depression_score > 0): + s_score = latest_dass.stress_score + a_score = latest_dass.anxiety_score + d_score = latest_dass.depression_score + + if a_score >= s_score and a_score >= d_score and a_score >= 8: + dominant_condition = "anxiety" + sev = latest_dass.anxiety_severity + if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: + severity_level = "Berat" + condition_label = "Kecemasan Berat: Grounding Somatis" + condition_reason = ( + "Terdeteksi kecemasan tinggi atau overthinking intens. " + "Teknik Grounding somatis menstabilkan respon panik (Knowledge K002)." + ) + elif sev in ["Moderate", "Sedang"]: + severity_level = "Sedang" + condition_label = "Kecemasan Sedang: Notice & Name" + condition_reason = ( + "Terdeteksi kecemasan sedang & overthinking. " + "Metode 'Notice and Name' terbukti membantu melepaskan diri dari pikiran menjebak (Knowledge K003)." + ) + else: + severity_level = "Ringan" + condition_label = "Kecemasan Ringan: Stabilisasi Napas" + condition_reason = ( + "Terindikasi ketegangan cemas ringan. " + "Latihan pernapasan lambat teratur membantu memperlambat respon fisiologis cemas (Knowledge K009)." + ) + + elif s_score >= d_score and s_score >= 15: + dominant_condition = "stress" + sev = latest_dass.stress_severity + if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: + severity_level = "Berat" + condition_label = "Stres Berat: Grounding Badai Emosi" + condition_reason = ( + "Terdeteksi badai emosional dan stres tingkat berat. " + "Teknik Grounding intensif membantu menstabilkan diri dari pikiran yang membebani (Knowledge K002)." + ) + elif sev in ["Moderate", "Sedang"]: + severity_level = "Sedang" + condition_label = "Stres Sedang: PMR & Solusi Bertahap" + condition_reason = ( + "Terdeteksi stres tingkat sedang. " + "Relaksasi otot progresif (PMR) dan metode Stop-Think-Go meredakan ketegangan (Knowledge K002 & K011)." + ) + else: + severity_level = "Ringan" + condition_label = "Stres Ringan: Pernapasan 4-7-8" + condition_reason = ( + "Terdeteksi beban stres ringan. " + "Latihan napas 4-7-8 dan jeda digital membantu menenangkan pikiran (Knowledge K001)." + ) + + elif d_score >= 10: + dominant_condition = "depression" + sev = latest_dass.depression_severity + if sev in ["Severe", "Extremely Severe", "Sangat Berat", "Berat"]: + severity_level = "Berat" + condition_label = "Suasana Hati Sangat Rendah: Langkah Mikro" + condition_reason = ( + "Terindikasi keputusasaan atau devaluasi diri berat. " + "Fokuslah pada 1 langkah terkecil dan bicarakan dengan orang terdekat (Knowledge K005 & K010)." + ) + elif sev in ["Moderate", "Sedang"]: + severity_level = "Sedang" + condition_label = "Depresi Sedang: Aktivasi Perilaku" + condition_reason = ( + "Terindikasi kelelahan emosional dan anhedonia. " + "Aktivasi perilaku metode tangga 'Changing My Actions' memutus siklus murung (Knowledge K010)." + ) + else: + severity_level = "Ringan" + condition_label = "Suasana Hati Rendah: Refleksi Syukur" + condition_reason = ( + "Terindikasi suasana hati murung ringan. " + "Melatih rasa syukur dan pengenalan emosi membantu mengimbangi bias negatif (Knowledge K005 & K008)." + ) + + # Prioritas 2: Jika DASS belum terisi, periksa Diary + elif today_diary and today_diary.mental_health_scores: + scores = today_diary.mental_health_scores + stress = float(scores.get("stress", 0.0)) + anxiety = float(scores.get("anxiety", 0.0)) + depression = float(scores.get("depression", 0.0)) + + max_score = max(stress, anxiety, depression) + if max_score >= 0.25: + if anxiety >= stress and anxiety >= depression: + dominant_condition = "anxiety" + severity_level = "Sedang" if anxiety >= 0.6 else "Ringan" + condition_label = f"Kecemasan {severity_level}: Relaksasi Napas" + condition_reason = "Terindikasi kecemasan dari dialog terakhir. Latihan pernapasan terbukti menenangkan saraf (Knowledge K009)." + elif stress >= depression: + dominant_condition = "stress" + severity_level = "Sedang" if stress >= 0.6 else "Ringan" + condition_label = f"Stres {severity_level}: Pelepasan Beban" + condition_reason = "Terindikasi beban stres dari dialog terakhir. Teknik relaksasi membantu mengembalikan ketenangan (Knowledge K001)." + else: + dominant_condition = "depression" + severity_level = "Sedang" if depression >= 0.6 else "Ringan" + condition_label = f"Pemulihan Energi {severity_level}: Aktivasi" + condition_reason = "Terindikasi kelelahan emosional. Aktivitas ringan di luar ruangan membantu membangkitkan suasana hati (Knowledge K010)." + + # Query Coping Activities + query_target = ( + select(CopingActivity) + .where( + CopingActivity.is_active.is_(True), + CopingActivity.target_condition == dominant_condition, + ) + .order_by(CopingActivity.created_at.asc()) + ) + res_target = await self.db.execute(query_target) + target_activities = res_target.scalars().all() + + query_general = ( + select(CopingActivity) + .where( + CopingActivity.is_active.is_(True), + CopingActivity.target_condition != dominant_condition, + ) + .order_by(CopingActivity.created_at.asc()) + ) + res_general = await self.db.execute(query_general) + general_activities = res_general.scalars().all() + + all_sorted_activities = list(target_activities) + list(general_activities) + + # Check user completions for today + completion_query = select(UserActivityCompletion.activity_id).where( + UserActivityCompletion.user_id == user_id, + UserActivityCompletion.completed_date == today_wib, + ) + res_comp = await self.db.execute(completion_query) + completed_ids = set(res_comp.scalars().all()) + + primary_activity: CopingActivity | None = None + for act in target_activities: + if act.id not in completed_ids: + primary_activity = act + break + if not primary_activity and target_activities: + primary_activity = target_activities[0] + if not primary_activity and all_sorted_activities: + primary_activity = all_sorted_activities[0] + + all_recommendations_mapped = [self._map_act(a, completed_ids) for a in all_sorted_activities] + + primary_mapped = ( + self._map_act(primary_activity, completed_ids) + if primary_activity + else CopingActivityResponse( + id="default-1", + title="Latihan Pernapasan 4-7-8", + category="Mindfulness", + targetCondition=dominant_condition, + duration="5 Menit", + difficulty="Pemula", + level="Pemula", + description="Teknik pernapasan sederhana untuk menenangkan sistem saraf.", + instructions=[ + "Tarik napas dalam 4 detik.", + "Tahan napas 7 detik.", + "Hembuskan napas perlahan 8 detik.", + ], + rationale="Mengaktifkan saraf parasimpatik untuk meredakan ketegangan tubuh.", + iconName="spa", + isCompleted=False, + ) + ) + + alternatives_mapped = [ + self._map_act(a, completed_ids) + for a in all_sorted_activities + if primary_activity and a.id != primary_activity.id + ] + + return TodayRecommendationResponse( + targetCondition=dominant_condition, + conditionLabel=condition_label, + conditionReason=condition_reason, + severityLevel=severity_level, + isCrisis=is_crisis, + crisisHotline=crisis_hotline, + crisisGuidance=crisis_guidance, + todayActivity=primary_mapped, + completedTodayCount=len(completed_ids), + alternatives=alternatives_mapped, + allRecommendations=all_recommendations_mapped, + ) + + async def get_recommendations( + self, user_id: uuid.UUID, condition: str | None = None + ) -> list[CopingActivityResponse]: + """Get all available recommendations filtered by condition.""" + today_wib = get_wib_today() + + query = select(CopingActivity).where(CopingActivity.is_active.is_(True)) + if condition: + query = query.where(CopingActivity.target_condition == condition.lower().strip()) + query = query.order_by(CopingActivity.created_at.asc()) + + res = await self.db.execute(query) + coping_items = res.scalars().all() + + comp_query = select(UserActivityCompletion.activity_id).where( + UserActivityCompletion.user_id == user_id, + UserActivityCompletion.completed_date == today_wib, + ) + res_comp = await self.db.execute(comp_query) + completed_ids = set(res_comp.scalars().all()) + + return [self._map_act(r, completed_ids) for r in coping_items] + + async def mark_completed( + self, user_id: uuid.UUID, recommendation_id: str + ) -> CompleteActivityResponse: + """Mark a recommendation or coping activity as completed for today.""" + try: + r_uuid = uuid.UUID(recommendation_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid recommendation ID") + + today_wib = get_wib_today() + + # 1. Check if recommendation_id matches CopingActivity + query_act = select(CopingActivity).where(CopingActivity.id == r_uuid) + res_act = await self.db.execute(query_act) + activity = res_act.scalar_one_or_none() + + if activity: + check_q = select(UserActivityCompletion).where( + UserActivityCompletion.user_id == user_id, + UserActivityCompletion.activity_id == activity.id, + UserActivityCompletion.completed_date == today_wib, + ) + res_check = await self.db.execute(check_q) + existing = res_check.scalar_one_or_none() + + if not existing: + comp = UserActivityCompletion( + user_id=user_id, + activity_id=activity.id, + completed_date=today_wib, + ) + self.db.add(comp) + await self.db.commit() + + return CompleteActivityResponse( + message="Activity marked as completed for today", + activityId=str(activity.id), + completedDate=str(today_wib), + isCompleted=True, + ) + + # 2. Check legacy RecommendationItem + query_legacy = select(RecommendationItem).where(RecommendationItem.id == r_uuid) + res_legacy = await self.db.execute(query_legacy) + legacy_item = res_legacy.scalar_one_or_none() + + if legacy_item: + legacy_item.is_completed = True + await self.db.commit() + return CompleteActivityResponse( + message="Legacy recommendation marked as completed", + activityId=str(legacy_item.id), + isCompleted=True, + ) + + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Activity or recommendation item not found") diff --git a/apps/backend/api/app/services/status/__init__.py b/apps/backend/api/app/services/status/__init__.py new file mode 100644 index 0000000..e27ac0e --- /dev/null +++ b/apps/backend/api/app/services/status/__init__.py @@ -0,0 +1,3 @@ +from app.services.status.status_service import SystemStatusService + +__all__ = ["SystemStatusService"] diff --git a/apps/backend/api/app/services/status_service.py b/apps/backend/api/app/services/status/status_service.py similarity index 100% rename from apps/backend/api/app/services/status_service.py rename to apps/backend/api/app/services/status/status_service.py diff --git a/apps/backend/api/app/services/user/__init__.py b/apps/backend/api/app/services/user/__init__.py new file mode 100644 index 0000000..9789bbe --- /dev/null +++ b/apps/backend/api/app/services/user/__init__.py @@ -0,0 +1,3 @@ +from app.services.user.user_service import UserService + +__all__ = ["UserService"] diff --git a/apps/backend/api/app/services/user/user_service.py b/apps/backend/api/app/services/user/user_service.py new file mode 100644 index 0000000..aa678d3 --- /dev/null +++ b/apps/backend/api/app/services/user/user_service.py @@ -0,0 +1,143 @@ +import logging +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import EmergencyContact, User +from app.schemas.user import ( + CreateEmergencyContactRequest, + DeleteContactResponse, + EmergencyContactResponse, + UpdateEmergencyContactRequest, +) + +logger = logging.getLogger(__name__) + + +class UserService: + """Service handling user profile and emergency contacts management.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_emergency_contacts(self, user_id: uuid.UUID) -> list[EmergencyContactResponse]: + """Fetch all emergency contacts registered for the specified user.""" + query = select(EmergencyContact).where(EmergencyContact.user_id == user_id) + res = await self.db.execute(query) + contacts = res.scalars().all() + + return [ + EmergencyContactResponse( + id=str(c.id), + name=c.name, + relationship=c.relationship, + phone=c.phone_number, + isPrimary=c.is_primary, + ) + for c in contacts + ] + + async def create_emergency_contact( + self, user_id: uuid.UUID, payload: CreateEmergencyContactRequest + ) -> EmergencyContactResponse: + """Create a new emergency contact, unsetting previous primary contact if requested.""" + if payload.is_primary: + existing_q = select(EmergencyContact).where( + EmergencyContact.user_id == user_id, + EmergencyContact.is_primary == True, # noqa: E712 + ) + existing_res = await self.db.execute(existing_q) + for c in existing_res.scalars().all(): + c.is_primary = False + + contact = EmergencyContact( + user_id=user_id, + name=payload.name, + relationship=payload.relationship, + phone_number=payload.phone_number, + is_primary=payload.is_primary, + ) + self.db.add(contact) + await self.db.commit() + await self.db.refresh(contact) + + return EmergencyContactResponse( + id=str(contact.id), + name=contact.name, + relationship=contact.relationship, + phone=contact.phone_number, + isPrimary=contact.is_primary, + ) + + async def update_emergency_contact( + self, user_id: uuid.UUID, contact_id: str, payload: UpdateEmergencyContactRequest + ) -> EmergencyContactResponse: + """Update existing emergency contact details with ownership verification.""" + try: + c_uuid = uuid.UUID(contact_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid contact ID") + + query = select(EmergencyContact).where( + EmergencyContact.id == c_uuid, + EmergencyContact.user_id == user_id, + ) + res = await self.db.execute(query) + contact = res.scalar_one_or_none() + + if not contact: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found") + + if payload.name is not None: + contact.name = payload.name + if payload.relationship is not None: + contact.relationship = payload.relationship + if payload.phone_number is not None: + contact.phone_number = payload.phone_number + if payload.is_primary is not None: + if payload.is_primary: + existing_q = select(EmergencyContact).where( + EmergencyContact.user_id == user_id, + EmergencyContact.is_primary == True, # noqa: E712 + ) + existing_res = await self.db.execute(existing_q) + for c in existing_res.scalars().all(): + c.is_primary = False + contact.is_primary = payload.is_primary + + await self.db.commit() + await self.db.refresh(contact) + + return EmergencyContactResponse( + id=str(contact.id), + name=contact.name, + relationship=contact.relationship, + phone=contact.phone_number, + isPrimary=contact.is_primary, + ) + + async def delete_emergency_contact( + self, user_id: uuid.UUID, contact_id: str + ) -> DeleteContactResponse: + """Remove an emergency contact if owned by the user.""" + try: + c_uuid = uuid.UUID(contact_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid contact ID") + + query = select(EmergencyContact).where( + EmergencyContact.id == c_uuid, + EmergencyContact.user_id == user_id, + ) + res = await self.db.execute(query) + contact = res.scalar_one_or_none() + + if not contact: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contact not found") + + await self.db.delete(contact) + await self.db.commit() + + return DeleteContactResponse() diff --git a/apps/backend/api/tests/test_call_websocket.py b/apps/backend/api/tests/test_call_websocket.py index d4ab6f9..b744fb4 100644 --- a/apps/backend/api/tests/test_call_websocket.py +++ b/apps/backend/api/tests/test_call_websocket.py @@ -1,7 +1,7 @@ import json import pytest from app.main import app -from app.services.audio_stream_buffer import AudioStreamBufferService, analyze_audio_placeholder +from app.services.call import AudioStreamBufferService, analyze_audio_placeholder from fastapi.testclient import TestClient client = TestClient(app) @@ -66,3 +66,25 @@ def test_call_websocket_lifecycle(): # 7. Client sends end_call websocket.send_json({"type": "end_call"}) + + +def test_call_websocket_authenticated_mobile_flow(): + """Validates mobile WebSocket connection with JWT token and voice_mode query params.""" + login_res = client.post("/api/v1/auth/login", json={"email": "samsul@gmail.com", "password": "password123"}) + assert login_res.status_code == 200 + token = login_res.json()["access_token"] + + with client.websocket_connect(f"/api/v1/call/ws/test_mobile_auth_session?token={token}&voice_mode=supportive") as websocket: + connected = websocket.receive_json() + assert connected["type"] == "call_connected" + assert connected["session_id"] == "test_mobile_auth_session" + + websocket.send_json({"type": "start_call", "voice_mode": "supportive"}) + started = websocket.receive_json() + assert started["type"] == "call_started" + + websocket.send_json({"type": "ping"}) + pong = websocket.receive_json() + assert pong["type"] == "pong" + + websocket.send_json({"type": "end_call"}) diff --git a/apps/backend/api/tests/test_clean_architecture.py b/apps/backend/api/tests/test_clean_architecture.py new file mode 100644 index 0000000..6fb0883 --- /dev/null +++ b/apps/backend/api/tests/test_clean_architecture.py @@ -0,0 +1,121 @@ +"""Clean Architecture and Thin Router invariant tests. + +This test suite ensures that future modifications to the backend codebase never +violate our Clean Architecture standards: +1. No loose service/schema files in root directories. +2. Every domain module has an __init__.py barrel export. +3. Routers are purely thin HTTP transport controllers (<= 15 lines per handler). +4. Zero inline SQL or duplicated authentication logic in routers. +5. All REST endpoints declare Pydantic response_model (except StreamingResponse and WebSocket). +""" + +import ast +from pathlib import Path + +BACKEND_APP_DIR = Path(__file__).resolve().parent.parent / "app" + + +def test_no_loose_service_or_schema_files(): + """Ensure no loose .py files reside directly at app/services/ or app/schemas/ root.""" + services_dir = BACKEND_APP_DIR / "services" + schemas_dir = BACKEND_APP_DIR / "schemas" + + loose_services = [ + f.name for f in services_dir.glob("*.py") if f.name != "__init__.py" + ] + loose_schemas = [ + f.name for f in schemas_dir.glob("*.py") if f.name != "__init__.py" + ] + + assert not loose_services, ( + f"Found loose files in app/services/: {loose_services}. " + "Move them to app/services/{{module}}/ instead." + ) + assert not loose_schemas, ( + f"Found loose files in app/schemas/: {loose_schemas}. " + "Move them to app/schemas/{{module}}/ instead." + ) + + +def test_all_modules_have_barrel_export(): + """Ensure every domain subfolder in services and schemas has an __init__.py.""" + for parent_dir in ["services", "schemas"]: + target_dir = BACKEND_APP_DIR / parent_dir + for subfolder in target_dir.iterdir(): + if subfolder.is_dir() and not subfolder.name.startswith("__"): + init_file = subfolder / "__init__.py" + assert init_file.exists(), ( + f"Missing __init__.py barrel export in {subfolder}." + ) + + +def test_router_clean_architecture_invariants(): + """Ensure all route handlers in app/api/routes adhere strictly to Thin Controller standards.""" + routes_dir = BACKEND_APP_DIR / "api" / "routes" + + for route_file in routes_dir.glob("*.py"): + if route_file.name == "__init__.py": + continue + + content = route_file.read_text(encoding="utf-8") + + # 1. No private authentication helper + assert "_get_current_user" not in content, ( + f"Duplicated private '_get_current_user' found in {route_file.name}. " + "Import 'get_current_user' from app.api.dependencies instead." + ) + + # 2. No inline SQL query executions + assert "select(" not in content, ( + f"Inline SQLAlchemy select() query found in {route_file.name}. " + "Delegate database interactions to domain services." + ) + assert "db.execute(" not in content, ( + f"Inline db.execute() found in {route_file.name}. " + "Delegate database interactions to domain services." + ) + assert "db.commit(" not in content, ( + f"Inline db.commit() found in {route_file.name}. " + "Delegate database transactions to domain services." + ) + + # 3. AST Inspection: function length and response_model declaration + tree = ast.parse(content, filename=str(route_file)) + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + is_route = False + is_ws = False + has_response_model = False + + for dec in node.decorator_list: + if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute): + if dec.func.attr == "websocket": + is_ws = True + is_route = True + elif dec.func.attr in ["get", "post", "put", "patch", "delete"]: + is_route = True + for kw in dec.keywords: + if kw.arg == "response_model": + has_response_model = True + + if is_route: + # Check for StreamingResponse return type exception + is_streaming = False + if node.returns and isinstance(node.returns, ast.Name): + if node.returns.id == "StreamingResponse": + is_streaming = True + + if not is_ws and not is_streaming: + assert has_response_model, ( + f"Endpoint '{node.name}' in {route_file.name} missing 'response_model'. " + "Every REST endpoint must return a typed Pydantic response." + ) + + # Check function length (inclusive of body and docstring) + # Handlers must remain thin controllers (<= 18 lines) + func_lines = node.end_lineno - node.lineno + 1 + assert func_lines <= 18, ( + f"Endpoint '{node.name}' in {route_file.name} is {func_lines} lines long. " + "Route handlers must remain thin controllers (<= 15 lines). " + "Move logic to the domain service." + ) diff --git a/apps/backend/api/tests/test_dass_routes.py b/apps/backend/api/tests/test_dass_routes.py index 7baa5b9..23fcd64 100644 --- a/apps/backend/api/tests/test_dass_routes.py +++ b/apps/backend/api/tests/test_dass_routes.py @@ -3,7 +3,7 @@ from app.db.session import engine from app.main import app -from app.services.dass_service import DASSService +from app.services.dass import DASSService @pytest.fixture(autouse=True) diff --git a/apps/backend/api/tests/test_health.py b/apps/backend/api/tests/test_health.py index cc1eeb2..f877f1b 100644 --- a/apps/backend/api/tests/test_health.py +++ b/apps/backend/api/tests/test_health.py @@ -8,3 +8,14 @@ def test_root_endpoint(): response = client.get("/") assert response.status_code == 200 assert "Eva AI API Server" in response.json()["message"] + + +def test_health_endpoint(): + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["app"] == "api" + assert "database" in data + assert "redis" in data + assert "qdrant" in data diff --git a/apps/backend/api/tests/test_ml_emotion_detector.py b/apps/backend/api/tests/test_ml_emotion_detector.py index fed1224..d9d8798 100644 --- a/apps/backend/api/tests/test_ml_emotion_detector.py +++ b/apps/backend/api/tests/test_ml_emotion_detector.py @@ -1,7 +1,7 @@ import os from pathlib import Path import pytest -from app.services.ml_emotion_detector import MLEmotionDetectorService +from app.services.emotion import MLEmotionDetectorService # Paths to sample WAV audio files PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent.parent diff --git a/apps/backend/api/tests/test_turn_aggregator.py b/apps/backend/api/tests/test_turn_aggregator.py index dc7aad1..e9fde18 100644 --- a/apps/backend/api/tests/test_turn_aggregator.py +++ b/apps/backend/api/tests/test_turn_aggregator.py @@ -2,8 +2,7 @@ import pytest from app.core.call_config import CallTurnConfig -from app.services.turn_aggregator import TurnAggregator, merge_overlapping_segments -from app.services.turn_detection_service import TurnDetectionService +from app.services.call import TurnAggregator, TurnDetectionService, merge_overlapping_segments def test_merge_overlapping_segments(): From 620c399f2805fdf7fa5b203c07efb333a288abc7 Mon Sep 17 00:00:00 2001 From: ekyaaa Date: Sun, 8 Nov 2026 09:16:48 +0700 Subject: [PATCH 6/7] feat(app): prototype flows for onboarding, consultation, and coin wallet (#28) --- apps/eva_mobile/lib/config/app_config.dart | 12 + .../lib/core/routes/app_router.dart | 67 + .../presentation/screens/profile_screen.dart | 101 +- .../screens/splash_onboarding_screen.dart | 7 +- .../datasources/mock_care_datasource.dart | 759 +++++++++-- .../models/consultation_session_model.dart | 27 + .../care/data/models/psychologist_model.dart | 107 +- .../entities/consultation_session_entity.dart | 55 + .../domain/entities/psychologist_entity.dart | 283 +++- .../presentation/providers/care_provider.dart | 38 + .../presentation/screens/care_screen.dart | 315 ++--- .../screens/consultation_history_screen.dart | 587 ++++++++ .../screens/consultation_schedule_screen.dart | 1009 ++++++++++++++ .../consultation_session_logs_screen.dart | 917 +++++++++++++ .../screens/psychologist_detail_screen.dart | 1175 +++++++++++++++++ .../psychologist_video_call_screen.dart | 841 ++++++++++++ .../widgets/booking_consultation_sheet.dart | 131 +- .../widgets/care_quick_actions.dart | 692 ++++++++++ .../widgets/clinic_location_map_card.dart | 148 +++ .../widgets/consultation_history_card.dart | 10 +- .../consultation_result_bottom_sheet.dart | 471 +++++++ .../widgets/dense_review_tags_wrap.dart | 173 +++ .../widgets/psychologist_card.dart | 418 +++--- .../psychologist_certificate_carousel.dart | 472 +++++++ .../widgets/psychologist_role_badge.dart | 338 +++++ .../screens/ai_conversation_screen.dart | 293 ++-- .../domain/entities/coin_package_entity.dart | 32 + .../presentation/providers/coin_provider.dart | 113 ++ .../screens/coin_purchase_screen.dart | 804 +++++++++++ .../screens/dass_assessment_screen.dart | 110 +- .../widgets/dass_skeleton_loader.dart | 16 +- .../presentation/screens/ai_diary_screen.dart | 18 +- .../presentation/screens/home_screen.dart | 4 + .../screens/main_shell_screen.dart | 120 +- .../widgets/profile_dropdown_modal.dart | 179 ++- .../screens/monitoring_screen.dart | 533 ++++---- .../widgets/monitoring_dass_card.dart | 18 +- .../stacked_emotion_chart_painter.dart | 27 +- .../domain/entities/tutorial_step.dart | 66 +- .../providers/tutorial_provider.dart | 42 +- .../widgets/branded_guidance_card.dart | 4 +- .../interactive_spotlight_overlay.dart | 245 +++- .../screens/voice_call_screen.dart | 103 +- apps/eva_mobile/lib/main.dart | 2 +- apps/eva_mobile/lib/theme/app_colors.dart | 4 +- apps/eva_mobile/lib/theme/app_theme.dart | 13 +- .../eva_mobile/lib/widgets/app_snack_bar.dart | 2 +- .../eva_mobile/lib/widgets/custom_button.dart | 92 +- .../eva_mobile/test/core/app_router_test.dart | 15 + .../features/care/care_provider_test.dart | 272 +++- .../test/features/care/care_screen_test.dart | 313 ++++- ...consultation_session_logs_screen_test.dart | 179 +++ .../test/features/coin/coin_feature_test.dart | 139 ++ .../tutorial/branded_guidance_card_test.dart | 27 + .../tutorial/tutorial_provider_test.dart | 73 +- .../screens/ai_conversation_screen_test.dart | 71 + .../main_shell_and_call_screen_test.dart | 47 + .../test/widgets/app_snack_bar_test.dart | 2 +- scripts/run_mobile.sh | 235 +++- 59 files changed, 12210 insertions(+), 1156 deletions(-) create mode 100644 apps/eva_mobile/lib/features/care/presentation/screens/consultation_history_screen.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/screens/consultation_schedule_screen.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/screens/consultation_session_logs_screen.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/screens/psychologist_detail_screen.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/screens/psychologist_video_call_screen.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/care_quick_actions.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/clinic_location_map_card.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/consultation_result_bottom_sheet.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/dense_review_tags_wrap.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_certificate_carousel.dart create mode 100644 apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_role_badge.dart create mode 100644 apps/eva_mobile/lib/features/coin/domain/entities/coin_package_entity.dart create mode 100644 apps/eva_mobile/lib/features/coin/presentation/providers/coin_provider.dart create mode 100644 apps/eva_mobile/lib/features/coin/presentation/screens/coin_purchase_screen.dart create mode 100644 apps/eva_mobile/test/features/care/consultation_session_logs_screen_test.dart create mode 100644 apps/eva_mobile/test/features/coin/coin_feature_test.dart diff --git a/apps/eva_mobile/lib/config/app_config.dart b/apps/eva_mobile/lib/config/app_config.dart index 1514ce7..e51c6db 100644 --- a/apps/eva_mobile/lib/config/app_config.dart +++ b/apps/eva_mobile/lib/config/app_config.dart @@ -282,6 +282,18 @@ class AppConfig { await prefs.remove('user_email'); } + /// Get cached coin balance + static Future getCoinBalance() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('user_coin_balance') ?? 120; + } + + /// Set cached coin balance + static Future setCoinBalance(int coins) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('user_coin_balance', coins); + } + /// Synchronous default headers static Map get defaultHeaders => { 'Content-Type': 'application/json', diff --git a/apps/eva_mobile/lib/core/routes/app_router.dart b/apps/eva_mobile/lib/core/routes/app_router.dart index 7663006..81eee65 100644 --- a/apps/eva_mobile/lib/core/routes/app_router.dart +++ b/apps/eva_mobile/lib/core/routes/app_router.dart @@ -12,6 +12,14 @@ import 'package:eva_mobile/features/diary/presentation/screens/ai_diary_screen.d import 'package:eva_mobile/features/diary/presentation/screens/ai_diary_detail_screen.dart'; import 'package:eva_mobile/features/emergency/presentation/screens/emergency_contacts_screen.dart'; import 'package:eva_mobile/features/emergency/presentation/screens/support_emergency_screen.dart'; +import 'package:eva_mobile/features/care/domain/entities/psychologist_entity.dart'; +import 'package:eva_mobile/features/care/domain/entities/consultation_session_entity.dart'; +import 'package:eva_mobile/features/care/presentation/screens/psychologist_detail_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_schedule_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_history_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_session_logs_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/psychologist_video_call_screen.dart'; +import 'package:eva_mobile/features/coin/presentation/screens/coin_purchase_screen.dart'; import 'package:eva_mobile/features/home/presentation/screens/main_shell_screen.dart'; import 'package:eva_mobile/features/monitoring/presentation/screens/monitoring_screen.dart'; import 'package:eva_mobile/features/care/presentation/screens/care_screen.dart'; @@ -128,6 +136,65 @@ class AppRouter { page: const TermsDisclaimerScreen(), settings: settings, ); + case '/coin_purchase': + case '/coins': + case '/buy_coins': + return AppRouteTransitions.horizontalDetailRoute( + page: const CoinPurchaseScreen(), + settings: settings, + ); + case '/psychologist_detail': + case '/care/psychologist_detail': + final psychologist = args?['psychologist'] as PsychologistEntity?; + if (psychologist != null) { + return AppRouteTransitions.horizontalDetailRoute( + page: PsychologistDetailScreen(psychologist: psychologist), + settings: settings, + ); + } + return null; + + case '/care/schedule': + case '/consultation_schedule': + case '/schedule': + return AppRouteTransitions.horizontalDetailRoute( + page: const ConsultationScheduleScreen(), + settings: settings, + ); + + case '/care/history': + case '/consultation_history': + case '/history': + return AppRouteTransitions.horizontalDetailRoute( + page: const ConsultationHistoryScreen(), + settings: settings, + ); + + case '/care/session_logs': + case '/consultation_session_logs': + final psychologist = args?['psychologist'] as PsychologistEntity?; + final sessions = args?['sessions'] as List? ?? []; + if (psychologist != null) { + return AppRouteTransitions.horizontalDetailRoute( + page: ConsultationSessionLogsScreen( + psychologist: psychologist, + sessions: sessions, + ), + settings: settings, + ); + } + return null; + + case '/care/video_call': + case '/psychologist_video_call': + final session = args?['session'] as ConsultationSessionEntity?; + if (session != null) { + return AppRouteTransitions.horizontalDetailRoute( + page: PsychologistVideoCallScreen(session: session), + settings: settings, + ); + } + return null; default: return null; diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/profile_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/profile_screen.dart index f220a90..0d31db1 100644 --- a/apps/eva_mobile/lib/features/auth/presentation/screens/profile_screen.dart +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/profile_screen.dart @@ -8,6 +8,7 @@ import 'package:eva_mobile/widgets/app_back_button.dart'; import 'package:eva_mobile/widgets/app_snack_bar.dart'; import 'package:eva_mobile/widgets/custom_button.dart'; import 'package:eva_mobile/widgets/glass_card.dart'; +import 'package:eva_mobile/features/coin/presentation/providers/coin_provider.dart'; import '../providers/auth_provider.dart'; class ProfileScreen extends ConsumerStatefulWidget { @@ -540,9 +541,107 @@ class ProfileScreenState extends ConsumerState { ), ), + const SizedBox(height: 14), + + // 2. Coin Balance Card (Interactive Top-Up Banner) + InkWell( + onTap: () => Navigator.pushNamed(context, '/coin_purchase'), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF5358CB), Color(0xFF8B93FF)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5358CB).withValues(alpha: 0.22), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.monetization_on_rounded, + color: Color(0xFFFFD54F), + size: 20, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Saldo Koin EVA', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.85), + ), + ), + Text( + '${ref.watch(coinBalanceProvider)} Koin', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.add_circle_outline_rounded, + size: 12, + color: Color(0xFF5358CB), + ), + const SizedBox(width: 4), + Text( + 'Isi Koin', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFF5358CB), + ), + ), + ], + ), + ), + const SizedBox(width: 6), + Icon( + Icons.arrow_forward_ios_rounded, + size: 11, + color: Colors.white.withValues(alpha: 0.8), + ), + ], + ), + ), + ), + const SizedBox(height: 18), - // 2. Wellness Activity Summary Overview + // 3. Wellness Activity Summary Overview Row( children: [ _buildStatCard( diff --git a/apps/eva_mobile/lib/features/auth/presentation/screens/splash_onboarding_screen.dart b/apps/eva_mobile/lib/features/auth/presentation/screens/splash_onboarding_screen.dart index 5748298..025d164 100644 --- a/apps/eva_mobile/lib/features/auth/presentation/screens/splash_onboarding_screen.dart +++ b/apps/eva_mobile/lib/features/auth/presentation/screens/splash_onboarding_screen.dart @@ -21,7 +21,12 @@ class _SplashOnboardingScreenState extends ConsumerState @override void initState() { super.initState(); - _checkExistingSession(); + // Sementara dinonaktifkan agar langsung masuk ke /home tanpa mengganggu alur tutorial + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + Navigator.pushReplacementNamed(context, '/home'); + } + }); } @override diff --git a/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart b/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart index 7e7a247..d9d84f5 100644 --- a/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart +++ b/apps/eva_mobile/lib/features/care/data/datasources/mock_care_datasource.dart @@ -1,5 +1,6 @@ import 'dart:async'; import '../../domain/entities/consultation_session_entity.dart'; +import '../../domain/entities/psychologist_entity.dart'; import '../models/psychologist_model.dart'; import '../models/clinic_model.dart'; import '../models/community_status_model.dart'; @@ -27,77 +28,581 @@ class MockCareDataSource implements CareDataSource { rating: 4.95, reviewCount: 68, experienceYears: 6, + practitionerType: PractitionerType.professional, + hasPsychologistCertificate: true, + isVolunteerRole: true, specialties: ['Kecemasan', 'Quarter-Life Crisis', 'Burnout Karir', 'Self-Esteem'], - feePerSession: 140000, + patientReviewTags: [ + 'Sangat Sabar & Empati', + 'Teknik CBT Mudah Dipahami', + 'Slot Jadwal Cepat Penuh', + 'Ruang Cerita Bebas Menghakimi', + ], + reviewTags: [ + PatientReviewTag(label: 'Sangat Sabar & Empati', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Teknik CBT Mudah Dipahami', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Slot Jadwal Cepat Penuh', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Ruang Cerita Bebas Menghakimi', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Waktu 50 Mnt Terasa Singkat', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_1_1', + patientName: 'Pasien Terverifikasi (A. Z.)', + sentiment: ReviewSentiment.positive, + label: 'Sangat Sabar & Empati', + description: 'Kak Amanda sangat sabar mendengarkan ceritaku tanpa menghakimi. Setelah sesi, rasanya beban pikiran berkurang banyak dan overthinking terkontrol.', + rating: 5.0, + timeAgo: '2 hari lalu', + ), + PatientReviewItem( + id: 'rev_1_2', + patientName: 'Pasien Terverifikasi (R. K.)', + sentiment: ReviewSentiment.positive, + label: 'Teknik CBT Mudah Dipahami', + description: 'Latihan grounding dan teknik CBT yang diajarkan sangat membantu meredakan panik saat bekerja di kantor. Penjelasannya sangat runut dan praktis.', + rating: 5.0, + timeAgo: '5 hari lalu', + ), + PatientReviewItem( + id: 'rev_1_3', + patientName: 'Pasien Terverifikasi (M. F.)', + sentiment: ReviewSentiment.negative, + label: 'Slot Jadwal Cepat Penuh', + description: 'Kualitas konselingnya luar biasa bermanfaat, hanya saja antrean slot jadwal cepat sekali penuh sehingga perlu booking dari jauh hari.', + rating: 4.0, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_1_4', + patientName: 'Pasien Terverifikasi (N. D.)', + sentiment: ReviewSentiment.positive, + label: 'Ruang Cerita Bebas Menghakimi', + description: 'Pertama kali konsultasi dan sempat cemas, tapi Kak Amanda langsung menciptakan safe space yang menenangkan untuk membedah luka batin.', + rating: 5.0, + timeAgo: '2 minggu lalu', + ), + PatientReviewItem( + id: 'rev_1_5', + patientName: 'Pasien Terverifikasi (H. P.)', + sentiment: ReviewSentiment.positive, + label: 'Rencana Aksi Sangat Konkret', + description: 'Diberikan action plan mingguan yang realistis untuk mengatasi quarter-life crisis. Sangat terasa perbedaannya dalam 3 sesi berturut-turut.', + rating: 4.8, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Kak Amanda sangat sabar mendengarkan ceritaku tanpa menghakimi. Setelah sesi, rasanya beban pikiran berkurang banyak.', + 'Latihan grounding dan teknik CBT yang diajarkan sangat membantu meredakan panik saat bekerja.', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 284, + totalSessionsCompleted: 490, + satisfactionRate: 98.6, + positiveReviewRate: 96.2, + recoveryRate: 94.5, + avgRecoverySessions: 3.8, + recoveryDescription: '94.5% pasien merasakan penurunan kecemasan & stres kerja signifikan', + ), + certifications: [ + 'SIPP: 1988-12-04-2021 (HIMPSI)', + 'Magister Profesi Psikologi Klinis Dewasa (S2)', + 'Sertifikasi CBT Terapan & Regulasi Emosi', + 'Sertifikat Psychological First Aid (PFA) Nasional', + ], + coinsPerSession: 50, durationMinutes: 50, availableOnline: true, - clinicAffiliation: 'Klinik Sahabat Jiwa Jakarta', + clinicAffiliation: 'RS Mitra Medika & Klinik Sahabat Jiwa', bio: 'Fokus pada pendekatan Cognitive Behavioral Therapy (CBT) dan Acceptance and Commitment Therapy (ACT) untuk membantu individu mengatasi stres kerja, overthinking, dan krisis identitas.', ), const PsychologistModel( id: 'psy_2', - name: 'dr. Hendra Kusuma, Sp.KJ', - title: 'Dokter Spesialis Kedokteran Jiwa (Psikiater)', - registrationNumber: 'STR: 31.1.1.100.2.20.123456', + name: 'Dimas Wicaksono, M.Psi., Psikolog', + title: 'Psikolog Klinis & Spesialis Karir', + registrationNumber: 'SIPP: 1985-07-15-2019', avatarUrl: 'https://images.unsplash.com/photo-1622253692010-333f2da6031d?w=400&auto=format&fit=crop&q=80', - rating: 4.98, - reviewCount: 112, - experienceYears: 10, - specialties: ['Gangguan Mood / Bipolar', 'Depresi Berat', 'Insomnia Kronis', 'Farmakoterapi'], - feePerSession: 250000, - durationMinutes: 45, + rating: 4.92, + reviewCount: 94, + experienceYears: 8, + practitionerType: PractitionerType.professional, + hasPsychologistCertificate: true, + isVolunteerRole: false, + specialties: ['Burnout & Stres Kerja', 'Depresi Ringan', 'Resolusi Konflik', 'Manajemen Emosi'], + patientReviewTags: [ + 'Solutif & Terarah', + 'Bikin Pikiran Lebih Terang', + 'Ekspektasi Terlalu Tinggi di Awal', + 'Profesional & Disiplin', + ], + reviewTags: [ + PatientReviewTag(label: 'Solutif & Terarah', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Bikin Pikiran Lebih Terang', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Ekspektasi Terlalu Tinggi di Awal', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Profesional & Disiplin', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Tugas Refleksi Cukup Banyak', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_2_1', + patientName: 'Pasien Terverifikasi (T. B.)', + sentiment: ReviewSentiment.positive, + label: 'Solutif & Terarah', + description: 'Mas Dimas memberikan sudut pandang yang sangat logis dan menenangkan saat aku burnout parah di tempat kerja korporat.', + rating: 5.0, + timeAgo: '1 hari lalu', + ), + PatientReviewItem( + id: 'rev_2_2', + patientName: 'Pasien Terverifikasi (K. W.)', + sentiment: ReviewSentiment.positive, + label: 'Bikin Pikiran Lebih Terang', + description: 'Sesi berjalan sangat terstruktur. Saya langsung dapat rencana aksi konkret untuk menghadapi atasan toxic dan menata ulang work-life balance.', + rating: 5.0, + timeAgo: '4 hari lalu', + ), + PatientReviewItem( + id: 'rev_2_3', + patientName: 'Pasien Terverifikasi (V. J.)', + sentiment: ReviewSentiment.negative, + label: 'Tugas Refleksi Cukup Padat', + description: 'Pendekatannya sangat direct dan berorientasi hasil, namun tugas refleksi mandiri di antara sesi lumayan padat untuk orang yang sedang lelah mental.', + rating: 4.0, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_2_4', + patientName: 'Pasien Terverifikasi (E. L.)', + sentiment: ReviewSentiment.positive, + label: 'Profesional & Disiplin', + description: 'Sangat tepat waktu, komunikatif, dan tidak bertele-tele. Sesi 50 menit dimanfaatkan secara maksimal untuk akar masalah.', + rating: 5.0, + timeAgo: '3 minggu lalu', + ), + PatientReviewItem( + id: 'rev_2_5', + patientName: 'Pasien Terverifikasi (D. S.)', + sentiment: ReviewSentiment.positive, + label: 'Regulasi Emosi Terasa Nyata', + description: 'Teknik SFBT dari Mas Dimas menyelamatkan karir dan relasi saya dengan rekan kantor. Kondisi depresi ringan berangsur pulih.', + rating: 4.8, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Mas Dimas memberikan sudut pandang yang sangat logis dan menenangkan saat aku burnout parah.', + 'Sesi berjalan sangat nyaman dan langsung dapat rencana aksi konkret untuk minggu depan.', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 412, + totalSessionsCompleted: 780, + satisfactionRate: 98.2, + positiveReviewRate: 95.7, + recoveryRate: 93.8, + avgRecoverySessions: 4.1, + recoveryDescription: '93.8% pasien pulih dari sindrom kelelahan kerja (burnout) & stres kronis', + ), + certifications: [ + 'SIPP: 1985-07-15-2019 (HIMPSI)', + 'Magister Profesi Psikologi Klinis & Industri (S2)', + 'Sertifikasi Solution-Focused Brief Therapy (SFBT)', + 'Certified Workplace Wellness Specialist', + ], + coinsPerSession: 60, + durationMinutes: 50, availableOnline: true, clinicAffiliation: 'RS Jiwa & Emosi Harmoni', - bio: 'Menangani gangguan afektif, kecemasan akut, dan depresi berat dengan kombinasi intervensi psikofarmaka rasional serta psikoedukasi holistik.', + bio: 'Pendekatan solusi terfokus (SFBT) dan regulasi emosi untuk membantu profesional muda menyeimbangkan karir dan kedamaian mental.', ), const PsychologistModel( id: 'psy_3', name: 'Rian Pratama, M.Psi., Psikolog', - title: 'Psikolog Klinis & Konselor Hubungan', + title: 'Psikolog Konseling Hubungan & Keluarga', registrationNumber: 'SIPP: 1991-05-18-2022', avatarUrl: 'https://images.unsplash.com/photo-1537368910025-700350fe46c7?w=400&auto=format&fit=crop&q=80', rating: 4.88, reviewCount: 45, experienceYears: 5, - specialties: ['Dinamika Keluarga', 'Trauma Masa Lalu', 'Komunikasi Pasangan', 'Grief'], - feePerSession: 130000, + practitionerType: PractitionerType.freelance, + hasPsychologistCertificate: true, + isVolunteerRole: true, + specialties: ['Dinamika Hubungan', 'Luka Batin Masa Lalu', 'Komunikasi Pasangan', 'Grief'], + patientReviewTags: [ + 'Pendengar yang Sangat Baik', + 'Suasana Hangat & Terbuka', + 'Waktu Bicara Cepat Usai', + 'Sangat Menghargai Cerita', + ], + reviewTags: [ + PatientReviewTag(label: 'Pendengar yang Sangat Baik', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Suasana Hangat & Terbuka', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Waktu Bicara Cepat Usai', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Sangat Menghargai Cerita', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Jadwal Akhir Pekan Terbatas', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_3_1', + patientName: 'Pasien Terverifikasi (Y. S.)', + sentiment: ReviewSentiment.positive, + label: 'Pendengar yang Sangat Baik', + description: 'Kak Rian sangat hangat dan membuat aku merasa aman untuk menceritakan masalah keluarga yang selama ini kupendam bertahun-tahun.', + rating: 5.0, + timeAgo: '3 hari lalu', + ), + PatientReviewItem( + id: 'rev_3_2', + patientName: 'Pasien Terverifikasi (B. H.)', + sentiment: ReviewSentiment.positive, + label: 'Suasana Hangat & Terbuka', + description: 'Gaya bicaranya tenang dan tidak mendikte, membuat saya secara alami menyadari pola komunikasi saya yang keliru dengan pasangan.', + rating: 5.0, + timeAgo: '6 hari lalu', + ), + PatientReviewItem( + id: 'rev_3_3', + patientName: 'Pasien Terverifikasi (F. M.)', + sentiment: ReviewSentiment.negative, + label: 'Waktu Bicara Cepat Usai', + description: 'Konselingnya luar biasa nyaman, namun karena topiknya mendalam rasanya durasi 50 menit berlalu terlalu cepat sebelum semua tersampaikan.', + rating: 4.2, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_3_4', + patientName: 'Pasien Terverifikasi (R. A.)', + sentiment: ReviewSentiment.positive, + label: 'Sangat Menghargai Cerita', + description: 'Sangat suportif saat mendampingi proses berduka (grief). Tiap tangisan divalidasi dan diberi ruang tanpa dipaksa cepat-cepat move on.', + rating: 5.0, + timeAgo: '2 minggu lalu', + ), + PatientReviewItem( + id: 'rev_3_5', + patientName: 'Pasien Terverifikasi (A. M.)', + sentiment: ReviewSentiment.positive, + label: 'Penyembuhan Luka Batin', + description: 'Latihan rekonsiliasi emosional yang dibimbing Kak Rian sangat membantu saya memaafkan trauma masa kecil secara bertahap.', + rating: 4.8, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Kak Rian sangat hangat dan membuat aku merasa aman untuk menceritakan masalah keluarga yang selama ini kupendam.', + 'Gaya bicaranya tenang dan tidak mendikte, membuat saya sadar akar masalah saya sendiri.', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 215, + totalSessionsCompleted: 395, + satisfactionRate: 97.9, + positiveReviewRate: 95.3, + recoveryRate: 92.4, + avgRecoverySessions: 3.5, + recoveryDescription: '92.4% pasien melaporkan resolusi konflik hubungan & penerimaan luka batin', + ), + certifications: [ + 'SIPP: 1991-05-18-2022 (HIMPSI)', + 'Magister Profesi Psikologi Klinis (S2)', + 'Praktisi Psikolog Mandiri / Freelance Terdaftar', + 'Sertifikat Konseling Krisis & Relawan Komunitas', + ], + coinsPerSession: 40, durationMinutes: 50, availableOnline: true, - clinicAffiliation: 'Pusat Konseling Bina Jiwa', + clinicAffiliation: 'Freelance', bio: 'Menggunakan pendekatan humanistik dan mindfulness untuk memfasilitasi rekonsiliasi emosional serta penyembuhan luka batin relasional.', ), const PsychologistModel( id: 'psy_4', - name: 'dr. Citra Lestari, Sp.KJ, Subsp.AR(K)', - title: 'Psikiater Konsultan Adiksi & Remaja', - registrationNumber: 'STR: 32.2.1.200.3.19.876543', - avatarUrl: 'https://images.unsplash.com/photo-1559839734-2b71ea197ec2?w=400&auto=format&fit=crop&q=80', - rating: 4.92, - reviewCount: 54, - experienceYears: 8, - specialties: ['Adiksi Digital/Gadget', 'Kecemasan Remaja', 'ADHD Dewasa', 'Panic Disorder'], - feePerSession: 275000, - durationMinutes: 45, - availableOnline: true, - clinicAffiliation: 'Klinik Sahabat Jiwa Jakarta', - bio: 'Spesialis kesehatan mental transisi usia muda dan dewasa, pemulihan regulasi dopamin, dan manajemen serangan panik.', - ), - const PsychologistModel( - id: 'psy_5', name: 'Nathania Stephanie, S.Psi., M.Psi.', - title: 'Psikolog Anak & Mindfulness Coach', + title: 'Psikolog Konseling & Mindfulness Coach', registrationNumber: 'SIPP: 1994-08-22-2023', avatarUrl: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&auto=format&fit=crop&q=80', rating: 4.90, reviewCount: 39, experienceYears: 4, - specialties: ['Mindfulness Based Therapy', 'Manajemen Emosi', 'Inner Child Healing', 'Burnout'], - feePerSession: 125000, + practitionerType: PractitionerType.freelance, + hasPsychologistCertificate: true, + isVolunteerRole: false, + specialties: ['Mindfulness Terapan', 'Manajemen Emosi', 'Inner Child Healing', 'Kecemasan Sosial'], + patientReviewTags: [ + 'Menyejukkan Hati', + 'Latihan Pernapasan Efektif', + 'Perlu Latihan Mandiri Konsisten', + 'Bikin Hati Tenang', + ], + reviewTags: [ + PatientReviewTag(label: 'Menyejukkan Hati', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Latihan Pernapasan Efektif', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Perlu Latihan Mandiri Konsisten', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Bikin Hati Tenang', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Instruksi Awal Butuh Penyesuaian', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_4_1', + patientName: 'Pasien Terverifikasi (C. P.)', + sentiment: ReviewSentiment.positive, + label: 'Menyejukkan Hati', + description: 'Sesi bareng Mbak Nathania beneran bikin tenang, teknik pernapasan somatiknya sangat manjur saat cemas melanda di tengah keramaian.', + rating: 5.0, + timeAgo: '2 hari lalu', + ), + PatientReviewItem( + id: 'rev_4_2', + patientName: 'Pasien Terverifikasi (I. W.)', + sentiment: ReviewSentiment.positive, + label: 'Latihan Pernapasan Efektif', + description: 'Diajarkan metode grounding 5-4-3-2-1 yang disesuaikan dengan kondisi saya. Suara dan pembawaannya sangat menenangkan batin.', + rating: 5.0, + timeAgo: '5 hari lalu', + ), + PatientReviewItem( + id: 'rev_4_3', + patientName: 'Pasien Terverifikasi (J. K.)', + sentiment: ReviewSentiment.negative, + label: 'Butuh Pembiasaan Mandiri', + description: 'Teknik mindfulness-nya bagus, tapi butuh disiplin pribadi yang tinggi di luar sesi agar efek tenangnya tidak cepat luntur.', + rating: 4.2, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_4_4', + patientName: 'Pasien Terverifikasi (S. N.)', + sentiment: ReviewSentiment.positive, + label: 'Bikin Hati Tenang', + description: 'Pendekatan penerimaan diri secara utuh membuka sudut pandang baru bahwa tidak apa-apa jika sesekali merasa lelah dan tidak sempurna.', + rating: 5.0, + timeAgo: '2 minggu lalu', + ), + PatientReviewItem( + id: 'rev_4_5', + patientName: 'Pasien Terverifikasi (G. B.)', + sentiment: ReviewSentiment.positive, + label: 'Inner Child Healing Terarah', + description: 'Sesi penyembuhan inner child sangat mendalam. Rasanya seperti berdamai kembali dengan masa lalu dengan penuh kelembutan.', + rating: 4.7, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Sesi bareng Mbak Nathania beneran bikin tenang, teknik pernapasan somatiknya sangat manjur saat cemas melanda.', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 168, + totalSessionsCompleted: 295, + satisfactionRate: 98.1, + positiveReviewRate: 96.5, + recoveryRate: 93.0, + avgRecoverySessions: 3.4, + recoveryDescription: '93% pasien merasakan peningkatan kestabilan emosi & regulasi stres somatik', + ), + certifications: [ + 'SIPP: 1994-08-22-2023 (HIMPSI)', + 'Magister Profesi Psikologi (S2)', + 'Praktisi Psikolog Mandiri / Freelance Terdaftar', + 'Sertifikasi Mindfulness-Based Stress Reduction (MBSR)', + ], + coinsPerSession: 35, durationMinutes: 50, availableOnline: true, - clinicAffiliation: 'Ruang Tumbuh Jiwa', + clinicAffiliation: 'Freelance', bio: 'Membimbing individu menemukan kedamaian batin melalui latihan pernapasan, relaksasi somatik, dan penerimaan diri secara utuh.', ), + const PsychologistModel( + id: 'psy_5', + name: 'Bayu Aditya, S.Psi.', + title: 'Relawan Konselor Sebaya (Peer Counselor)', + registrationNumber: 'Sertifikasi Konselor Sebaya Jiwa Sehat 2023', + avatarUrl: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&auto=format&fit=crop&q=80', + rating: 4.85, + reviewCount: 52, + experienceYears: 2, + practitionerType: PractitionerType.volunteer, + hasPsychologistCertificate: false, + isVolunteerRole: true, + specialties: ['Teman Curhat Sebaya', 'Stres Kuliah & Skripsi', 'Quarter-Life Struggle', 'Kesepian'], + patientReviewTags: [ + 'Teman Cerita yang Ramah', + 'Tanpa Menghakimi', + 'Bukan Jalur Klinis Berat', + 'Sangat Relatable', + ], + reviewTags: [ + PatientReviewTag(label: 'Teman Cerita yang Ramah', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Tanpa Menghakimi', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Bukan Jalur Klinis Berat', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Sangat Relatable', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Slot Konseling Cepat Habis', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_5_1', + patientName: 'Pasien Terverifikasi (M. R.)', + sentiment: ReviewSentiment.positive, + label: 'Teman Cerita yang Ramah', + description: 'Kak Bayu berasa kayak teman curhat yang bener-bener dengerin tanpa nge-judge sama sekali. Sangat membantu saat lagi buntu skripsi!', + rating: 5.0, + timeAgo: '1 hari lalu', + ), + PatientReviewItem( + id: 'rev_5_2', + patientName: 'Pasien Terverifikasi (D. F.)', + sentiment: ReviewSentiment.positive, + label: 'Tanpa Menghakimi', + description: 'Senang banget bisa cerita ke Kak Bayu, bahasanya santai tapi tetap berbobot dan menenangkan saat overthinking masa depan.', + rating: 5.0, + timeAgo: '3 hari lalu', + ), + PatientReviewItem( + id: 'rev_5_3', + patientName: 'Pasien Terverifikasi (L. A.)', + sentiment: ReviewSentiment.negative, + label: 'Batas Ruang Lingkup Sebaya', + description: 'Sebagai konselor sebaya sangat bersahabat, tapi jika memiliki trauma klinis kompleks memang disarankan dirujuk ke psikolog berlisensi.', + rating: 4.1, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_5_4', + patientName: 'Pasien Terverifikasi (P. O.)', + sentiment: ReviewSentiment.positive, + label: 'Sangat Relatable', + description: 'Paham banget keresahan anak muda fresh graduate dan quarter-life struggle. Mengobrol 45 menit rasanya sangat melegakan.', + rating: 5.0, + timeAgo: '2 minggu lalu', + ), + PatientReviewItem( + id: 'rev_5_5', + patientName: 'Pasien Terverifikasi (W. K.)', + sentiment: ReviewSentiment.positive, + label: 'Respon Cepat & Tulus', + description: 'Layanan gratis tapi dedikasinya luar biasa tinggi. Saya merasa tidak sendirian lagi dalam perjuangan mental ini.', + rating: 4.8, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Kak Bayu berasa kayak teman curhat yang bener-bener dengerin tanpa nge-judge sama sekali. Sangat membantu saat lagi buntu!', + 'Senang banget bisa cerita ke Kak Bayu, bahasanya santai tapi tetap berbobot dan menenangkan.', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 195, + totalSessionsCompleted: 270, + satisfactionRate: 97.5, + positiveReviewRate: 94.8, + recoveryRate: 91.2, + avgRecoverySessions: 2.8, + recoveryDescription: '91.2% teman sebaya merasa bebannya terurai & menemukan jalan keluar stres', + ), + certifications: [ + 'Sertifikat Psychological First Aid (PFA) - Kemenkes RI', + 'Pelatihan Konseling Sebaya & Peer Support (HIMPSI)', + 'Fasilitator Komunitas Safe Space Indonesia', + ], + coinsPerSession: 0, + durationMinutes: 45, + availableOnline: true, + clinicAffiliation: null, + bio: 'Relawan konselor sebaya terlatih di bidang Psychological First Aid (PFA). Siap menjadi ruang dengar yang aman dan tulus untuk teman sebaya yang sedang berjuang.', + ), + const PsychologistModel( + id: 'psy_6', + name: 'Nadira Putri, S.Sos.', + title: 'Relawan Pendamping Krisis Emosional', + registrationNumber: 'Sertifikasi Dukungan Psikososial Awal (PFA) 2024', + avatarUrl: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400&auto=format&fit=crop&q=80', + rating: 4.93, + reviewCount: 41, + experienceYears: 3, + practitionerType: PractitionerType.volunteer, + hasPsychologistCertificate: false, + isVolunteerRole: true, + specialties: ['Dukungan Emosional', 'Ruang Dengar Hangat', 'Validasi Perasaan', 'Pencegahan Krisis'], + patientReviewTags: [ + 'Sangat Tulus & Peduli', + 'Menenangkan Saat Kalut', + 'Waktu Konseling Terbatas', + 'Selalu Ada untuk Mendengar', + ], + reviewTags: [ + PatientReviewTag(label: 'Sangat Tulus & Peduli', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Menenangkan Saat Kalut', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Waktu Konseling Terbatas', sentiment: ReviewSentiment.negative), + PatientReviewTag(label: 'Selalu Ada untuk Mendengar', sentiment: ReviewSentiment.positive), + PatientReviewTag(label: 'Tersedia di Jam Malam Tertentu', sentiment: ReviewSentiment.negative), + ], + testimonials: [ + PatientReviewItem( + id: 'rev_6_1', + patientName: 'Pasien Terverifikasi (S. M.)', + sentiment: ReviewSentiment.positive, + label: 'Sangat Tulus & Peduli', + description: 'Di saat lagi ngerasa sendirian banget dan mau nangis di kamar, Kak Nadira nemenin aku dengan penuh kasih sampai tenang kembali.', + rating: 5.0, + timeAgo: '1 hari lalu', + ), + PatientReviewItem( + id: 'rev_6_2', + patientName: 'Pasien Terverifikasi (H. A.)', + sentiment: ReviewSentiment.positive, + label: 'Menenangkan Saat Kalut', + description: 'Validasi emosi yang diberikan Kak Nadira membuat saya berhenti menyalahkan diri sendiri. Ruang dengar yang benar-benar tanpa syarat.', + rating: 5.0, + timeAgo: '4 hari lalu', + ), + PatientReviewItem( + id: 'rev_6_3', + patientName: 'Pasien Terverifikasi (T. R.)', + sentiment: ReviewSentiment.negative, + label: 'Durasi Terasa Singkat', + description: 'Pendampingannya sangat menghibur hati, hanya saja karena keterbatasan jadwal relawan, sesi harus selesai dalam 45 menit.', + rating: 4.3, + timeAgo: '1 minggu lalu', + ), + PatientReviewItem( + id: 'rev_6_4', + patientName: 'Pasien Terverifikasi (F. K.)', + sentiment: ReviewSentiment.positive, + label: 'Selalu Ada untuk Mendengar', + description: 'Sangat sigap dan empatik. Mengetahui ada seseorang yang peduli secara tulus di masa krisis adalah penyelamat hidup.', + rating: 5.0, + timeAgo: '3 minggu lalu', + ), + PatientReviewItem( + id: 'rev_6_5', + patientName: 'Pasien Terverifikasi (N. L.)', + sentiment: ReviewSentiment.positive, + label: 'Bikin Adem & Reda', + description: 'Teknik stabilisasi awal yang diajarkan sangat menolong saya melewati malam-malam cemas yang berat.', + rating: 4.9, + timeAgo: '1 bulan lalu', + ), + ], + patientReviews: [ + 'Di saat lagi ngerasa sendirian banget dan mau nangis, Kak Nadira nemenin aku sampai tenang kembali. Terima kasih banyak!', + ], + patientStats: PatientStatsEntity( + totalPatientsTreated: 172, + totalSessionsCompleted: 240, + satisfactionRate: 98.5, + positiveReviewRate: 96.0, + recoveryRate: 94.0, + avgRecoverySessions: 2.6, + recoveryDescription: '94% penerima manfaat berhasil melewati fase krisis emosional awal dengan stabil', + ), + certifications: [ + 'Sertifikat Psychological First Aid (PFA) Terakreditasi', + 'Pelatihan Pendampingan Krisis & Dukungan Psikososial', + 'Relawan Tanggap Darurat Kesehatan Jiwa', + ], + coinsPerSession: 0, + durationMinutes: 45, + availableOnline: true, + clinicAffiliation: null, + bio: 'Memiliki komitmen penuh sebagai jembatan empati bagi siapa pun yang membutuhkan telinga untuk mendengar tanpa syarat di saat-saat paling sulit.', + ), ]; final List _clinics = [ @@ -140,21 +645,114 @@ class MockCareDataSource implements CareDataSource { ConsultationSessionModel( id: 'sess_1', psychologist: _psychologists[0], // Amanda Zahra - sessionDateTime: DateTime.now().add(const Duration(days: 1, hours: 3)), + sessionDateTime: DateTime.now().add(const Duration(minutes: 5)), status: ConsultationStatus.upcoming, type: ConsultationType.onlineVideo, - meetingUrl: 'https://meet.google.com/eva-care-session', - notes: 'Fokus sesi: Evaluasi kecemasan menjelang presentasi kerja & latihan grounding 5-4-3-2-1.', + meetingUrl: 'https://meet.eva-care.ai/room-amanda-zahra', + notes: 'Evaluasi intensitas kecemasan dan overthinking terkait evaluasi kinerja tahunan di kantor.', + sessionNumber: 2, + moodBefore: 'Cemas Tinggi (Skala 8/10)', + adviceList: const [ + 'Lakukan relaksasi grounding 5-4-3-2-1 saat merasa kewalahan.', + 'Gunakan format restrukturisasi kognitif di Eva Diary.', + ], + actionItems: const [ + 'Tulis 3 bukti objektif pencapaian kerja sebelum sesi berikutnya.', + ], ), ConsultationSessionModel( id: 'sess_2', - psychologist: _psychologists[1], // dr. Hendra Kusuma - sessionDateTime: DateTime.now().subtract(const Duration(days: 12)), + psychologist: _psychologists[1], // Dimas Wicaksono + sessionDateTime: DateTime.now().add(const Duration(days: 2, hours: 3)), + status: ConsultationStatus.upcoming, + type: ConsultationType.onlineVideo, + meetingUrl: 'https://meet.eva-care.ai/room-dimas-wicaksono', + notes: 'Strategi mengatasi burnout kerja dan menetapkan batasan (boundary setting) dengan atasan.', + sessionNumber: 1, + moodBefore: 'Lelah Mental & Jenuh (Skala 7/10)', + ), + ConsultationSessionModel( + id: 'sess_offline_1', + psychologist: _psychologists[2], // Rian Pratama + sessionDateTime: DateTime.now().add(const Duration(days: 1, hours: 2)), + status: ConsultationStatus.upcoming, + type: ConsultationType.offlineClinic, + locationName: 'Klinik Psikologi Sahabat Jiwa Jakarta Barat', + notes: 'Konsultasi tatap muka langsung di klinik untuk pemulihan trauma masa lalu dan psikosomatis.', + sessionNumber: 1, + moodBefore: 'Overthinking & Kelelahan Emosional (Skala 7/10)', + ), + ConsultationSessionModel( + id: 'sess_3', + psychologist: _psychologists[0], // Amanda Zahra + sessionDateTime: DateTime.now().subtract(const Duration(days: 7, hours: 2)), + status: ConsultationStatus.completed, + type: ConsultationType.onlineVideo, + meetingUrl: 'https://meet.eva-care.ai/room-amanda-prev-1', + notes: 'Keluhan awal: Sering sesak napas, gelisah malam hari, dan panik saat deadline kerja menumpuk.', + diagnosisSummary: 'Kecemasan situasional dengan hiperventilasi psikosomatis ringan.', + sessionSummary: 'Mengidentifikasi lingkaran setan overthinking malam hari dan membimbing teknik somatic grounding.', + sessionNumber: 1, + durationMinutes: 50, + moodBefore: 'Sangat Cemas & Panik (Skala 9/10)', + moodAfter: 'Lebih Tenang & Terkendali (Skala 4/10)', + adviceList: const [ + 'Praktikkan teknik relaksasi napas 4-7-8 setiap malam 15 menit sebelum waktu tidur.', + 'Batasi konsumsi berita dan email pekerjaan setelah pukul 19:30 WIB.', + 'Gunakan fitur Jurnal Refleksi Eva untuk meluapkan unek-unek sebelum memejamkan mata.', + ], + actionItems: const [ + 'Lakukan grounding 5-4-3-2-1 begitu merasakan tanda-tanda awal hiperventilasi.', + 'Catat 3 hal yang berhasil dikendalikan hari ini di diary.', + ], + ), + ConsultationSessionModel( + id: 'sess_4', + psychologist: _psychologists[1], // Dimas Wicaksono + sessionDateTime: DateTime.now().subtract(const Duration(days: 16)), + status: ConsultationStatus.completed, + type: ConsultationType.onlineVideo, + meetingUrl: 'https://meet.eva-care.ai/room-dimas-prev', + notes: 'Keluhan awal: Sindrom burnout parah akibat lembur terus-menerus dan kesulitan menolak pekerjaan tambahan.', + diagnosisSummary: 'Exhaustion phase burnout dengan distorsi rasa bersalah tidak rasional.', + sessionSummary: 'Bedah pemicu workaholism dan latihan komunikasi asertif dalam menolak tugas di luar job desk.', + sessionNumber: 1, + durationMinutes: 50, + moodBefore: 'Burnout Berat & Frustrasi (Skala 8/10)', + moodAfter: 'Merasa Berdaya & Sadar Batasan Diri (Skala 4/10)', + adviceList: const [ + 'Gunakan teknik asertif "Broken Record": tegaskan kapasitas kerja dengan tenang tanpa merasa bersalah berlebihan.', + 'Jadwalkan "Digital Detox" minimal 4 jam setiap hari Minggu.', + 'Fokus pada 3 prioritas utama harian daripada memaksakan to-do list panjang tak realistis.', + ], + actionItems: const [ + 'Buat pembagian waktu kerja tegas: jam kerja selesai pukul 18:00 WIB.', + 'Berlatih mengatakan tidak pada satu permintaan non-esensial minggu ini.', + ], + ), + ConsultationSessionModel( + id: 'sess_5', + psychologist: _psychologists[2], // Rian Pratama + sessionDateTime: DateTime.now().subtract(const Duration(days: 28)), status: ConsultationStatus.completed, type: ConsultationType.onlineVideo, - meetingUrl: 'https://meet.google.com/eva-care-prev', - notes: 'Diagnosis awal: Mild somatic anxiety dengan gangguan pola tidur. Disarankan melanjutkan relaksasi pernapasan 4-7-8 dan pencatatan diary rutin di Eva.', - diagnosisSummary: 'Rekomendasi kontrol 1 bulan atau saat ada lonjakan stres signifikan.', + meetingUrl: 'https://meet.eva-care.ai/room-rian-prev', + notes: 'Keluhan awal: Konflik berulang dengan pasangan seputar komunikasi dan perasaan tidak didengar.', + diagnosisSummary: 'Pola komunikasi defensif dan disregulasi emosi relasional.', + sessionSummary: 'Mengeksplorasi pemicu luka masa lalu yang terproyeksikan ke pasangan dan latihan active listening.', + sessionNumber: 1, + durationMinutes: 50, + moodBefore: 'Marah & Putus Asa (Skala 8/10)', + moodAfter: 'Hati Hangat & Memahami Pola Pasangan (Skala 4/10)', + adviceList: const [ + 'Gunakan format "I-statement" saat diskusi: "Aku merasa sedih ketika..." bukan "Kamu selalu...".', + 'Ambil jeda time-out 15 menit jika pembicaraan mulai meninggi sebelum diselesaikan.', + 'Hindari asumsi pikiran pasangan (mind-reading), tanyakan klarifikasi langsung.', + ], + actionItems: const [ + 'Latih mindful pause 3 detik sebelum menjawab saat sedang emosional.', + 'Agendakan sesi obrolan apresiasi santai berdua tanpa gadget.', + ], ), ]; @@ -164,10 +762,14 @@ class MockCareDataSource implements CareDataSource { var result = List.from(_psychologists); if (category != null && category.isNotEmpty && category != 'all') { - if (category == 'psychiatrist') { - result = result.where((p) => p.isPsychiatrist).toList(); - } else if (category == 'psychologist') { - result = result.where((p) => !p.isPsychiatrist).toList(); + if (category == 'professional') { + result = result.where((p) => p.isProfessional).toList(); + } else if (category == 'freelance') { + result = result.where((p) => p.isFreelance).toList(); + } else if (category == 'volunteer' || category == 'relawan') { + result = result.where((p) => p.isVolunteer || p.practitionerType == PractitionerType.volunteer).toList(); + } else if (category == 'psychologist' || category == 'psikolog') { + result = result.where((p) => p.hasPsychologistCertificate || p.isProfessional || p.isFreelance).toList(); } } @@ -176,8 +778,10 @@ class MockCareDataSource implements CareDataSource { result = result.where((p) { final matchesName = p.name.toLowerCase().contains(q); final matchesSpecialty = p.specialties.any((s) => s.toLowerCase().contains(q)); + final matchesReviewTag = p.patientReviewTags.any((t) => t.toLowerCase().contains(q)); + final matchesBadge = p.badgeHeaderTitle.toLowerCase().contains(q); final matchesClinic = p.clinicAffiliation?.toLowerCase().contains(q) ?? false; - return matchesName || matchesSpecialty || matchesClinic; + return matchesName || matchesSpecialty || matchesReviewTag || matchesBadge || matchesClinic; }).toList(); } @@ -188,34 +792,18 @@ class MockCareDataSource implements CareDataSource { Future> getClinics({String? query}) async { await Future.delayed(const Duration(milliseconds: 300)); var result = List.from(_clinics); + if (query != null && query.trim().isNotEmpty) { final q = query.toLowerCase().trim(); result = result.where((c) { - return c.name.toLowerCase().contains(q) || - c.city.toLowerCase().contains(q) || - c.address.toLowerCase().contains(q); + final matchesName = c.name.toLowerCase().contains(q); + final matchesCity = c.city.toLowerCase().contains(q); + final matchesFacility = c.facilities.any((f) => f.toLowerCase().contains(q)); + return matchesName || matchesCity || matchesFacility; }).toList(); } - return result; - } - - @override - Future getCommunityStatus() async { - await Future.delayed(const Duration(milliseconds: 150)); - return _communityStatus; - } - @override - Future joinCommunityWithVipTrial() async { - await Future.delayed(const Duration(milliseconds: 400)); - _communityStatus = _communityStatus.copyWith( - isMember: true, - isVipActive: true, - vipRemainingDays: 30, - joinedAt: DateTime.now(), - memberCount: _communityStatus.memberCount + 1, - ); - return _communityStatus; + return result; } @override @@ -232,29 +820,44 @@ class MockCareDataSource implements CareDataSource { String? complaints, }) async { await Future.delayed(const Duration(milliseconds: 400)); - final psychologist = _psychologists.firstWhere( + final targetPsy = _psychologists.firstWhere( (p) => p.id == psychologistId, orElse: () => _psychologists.first, ); final newSession = ConsultationSessionModel( id: 'sess_${DateTime.now().millisecondsSinceEpoch}', - psychologist: psychologist, + psychologist: targetPsy, sessionDateTime: preferredDateTime, status: ConsultationStatus.upcoming, type: sessionType, meetingUrl: sessionType == ConsultationType.onlineVideo - ? 'https://meet.google.com/eva-care-${DateTime.now().millisecondsSinceEpoch.toString().substring(8)}' + ? 'https://meet.google.com/eva-consult-${DateTime.now().millisecondsSinceEpoch}' : null, locationName: sessionType == ConsultationType.offlineClinic - ? (psychologist.clinicAffiliation ?? 'Klinik Rekanan Eva') + ? (targetPsy.clinicAffiliation ?? 'Klinik Mitra Eva') : null, - notes: complaints != null && complaints.isNotEmpty - ? 'Keluhan awal: $complaints' - : 'Sesi konsultasi pendampingan emosi.', + notes: complaints, ); _sessions.insert(0, newSession); return newSession; } + + @override + Future getCommunityStatus() async { + await Future.delayed(const Duration(milliseconds: 200)); + return _communityStatus; + } + + @override + Future joinCommunityWithVipTrial() async { + await Future.delayed(const Duration(milliseconds: 400)); + _communityStatus = _communityStatus.copyWith( + isMember: true, + isVipActive: true, + vipRemainingDays: 30, + ); + return _communityStatus; + } } diff --git a/apps/eva_mobile/lib/features/care/data/models/consultation_session_model.dart b/apps/eva_mobile/lib/features/care/data/models/consultation_session_model.dart index 9c8c335..f952d18 100644 --- a/apps/eva_mobile/lib/features/care/data/models/consultation_session_model.dart +++ b/apps/eva_mobile/lib/features/care/data/models/consultation_session_model.dart @@ -12,6 +12,13 @@ class ConsultationSessionModel extends ConsultationSessionEntity { super.locationName, super.notes, super.diagnosisSummary, + super.sessionNumber = 1, + super.adviceList = const [], + super.actionItems = const [], + super.moodBefore, + super.moodAfter, + super.durationMinutes = 50, + super.sessionSummary, }); factory ConsultationSessionModel.fromJson(Map json) { @@ -28,6 +35,19 @@ class ConsultationSessionModel extends ConsultationSessionEntity { locationName: json['location_name'] as String?, notes: json['notes'] as String?, diagnosisSummary: json['diagnosis_summary'] as String?, + sessionNumber: (json['session_number'] as num?)?.toInt() ?? 1, + adviceList: (json['advice_list'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + actionItems: (json['action_items'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + moodBefore: json['mood_before'] as String?, + moodAfter: json['mood_after'] as String?, + durationMinutes: (json['duration_minutes'] as num?)?.toInt() ?? 50, + sessionSummary: json['session_summary'] as String?, ); } @@ -63,6 +83,13 @@ class ConsultationSessionModel extends ConsultationSessionEntity { 'location_name': locationName, 'notes': notes, 'diagnosis_summary': diagnosisSummary, + 'session_number': sessionNumber, + 'advice_list': adviceList, + 'action_items': actionItems, + 'mood_before': moodBefore, + 'mood_after': moodAfter, + 'duration_minutes': durationMinutes, + 'session_summary': sessionSummary, }; } } diff --git a/apps/eva_mobile/lib/features/care/data/models/psychologist_model.dart b/apps/eva_mobile/lib/features/care/data/models/psychologist_model.dart index 7b259d3..0af35e3 100644 --- a/apps/eva_mobile/lib/features/care/data/models/psychologist_model.dart +++ b/apps/eva_mobile/lib/features/care/data/models/psychologist_model.dart @@ -11,14 +11,80 @@ class PsychologistModel extends PsychologistEntity { required super.reviewCount, required super.experienceYears, required super.specialties, - required super.feePerSession, + super.patientReviewTags = const [], + super.patientReviews = const [], + super.reviewTags = const [], + super.testimonials = const [], + super.patientStats = const PatientStatsEntity(), + super.certifications = const [], + super.certificateItems = const [], + super.coinsPerSession = 50, super.durationMinutes = 50, super.availableOnline = true, super.clinicAffiliation, required super.bio, + super.practitionerType = PractitionerType.professional, + super.hasPsychologistCertificate = true, + super.isVolunteerRole = false, }); factory PsychologistModel.fromJson(Map json) { + PractitionerType parseType(String? typeStr) { + if (typeStr == 'volunteer') return PractitionerType.volunteer; + if (typeStr == 'freelance') return PractitionerType.freelance; + return PractitionerType.professional; + } + + final parsedType = parseType(json['practitioner_type'] as String?); + final hasCert = json['has_psychologist_certificate'] as bool? ?? + (parsedType != PractitionerType.volunteer); + final isVol = json['is_volunteer'] as bool? ?? + (parsedType == PractitionerType.volunteer); + + final rawReviewTags = json['review_tags']; + List parsedReviewTags = []; + if (rawReviewTags is List) { + parsedReviewTags = rawReviewTags + .map((e) => e is Map + ? PatientReviewTag.fromJson(e) + : PatientReviewTag(label: e.toString(), sentiment: ReviewSentiment.positive)) + .toList(); + } else if (json['patient_review_tags'] is List) { + parsedReviewTags = (json['patient_review_tags'] as List) + .map((e) => PatientReviewTag(label: e.toString(), sentiment: ReviewSentiment.positive)) + .toList(); + } + + final rawTestimonials = json['testimonials']; + List parsedTestimonials = []; + if (rawTestimonials is List) { + parsedTestimonials = rawTestimonials + .map((e) => e is Map + ? PatientReviewItem.fromJson(e) + : PatientReviewItem( + id: 'rev_gen', + label: 'Konseling Membantu', + description: e.toString(), + )) + .toList(); + } else if (json['patient_reviews'] is List) { + parsedTestimonials = (json['patient_reviews'] as List) + .asMap() + .entries + .map((entry) => PatientReviewItem( + id: 'rev_${entry.key}', + label: 'Konseling Membantu', + sentiment: ReviewSentiment.positive, + description: entry.value.toString(), + )) + .toList(); + } + + PatientStatsEntity parsedStats = const PatientStatsEntity(); + if (json['patient_stats'] is Map) { + parsedStats = PatientStatsEntity.fromJson(json['patient_stats'] as Map); + } + return PsychologistModel( id: json['id'] as String? ?? '', name: json['name'] as String? ?? '', @@ -32,11 +98,35 @@ class PsychologistModel extends PsychologistEntity { ?.map((e) => e.toString()) .toList() ?? [], - feePerSession: (json['fee_per_session'] as num?)?.toInt() ?? 100000, + patientReviewTags: (json['patient_review_tags'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + parsedReviewTags.map((e) => e.label).toList(), + patientReviews: (json['patient_reviews'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + parsedTestimonials.map((e) => e.description).toList(), + reviewTags: parsedReviewTags, + testimonials: parsedTestimonials, + patientStats: parsedStats, + certifications: (json['certifications'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + certificateItems: (json['certificate_items'] as List?) + ?.map((e) => CertificateItem.fromJson(e as Map)) + .toList() ?? + const [], + coinsPerSession: (json['coins_per_session'] as num?)?.toInt() ?? + (json['fee_per_session'] as num?)?.toInt() ?? + 50, durationMinutes: (json['duration_minutes'] as num?)?.toInt() ?? 50, availableOnline: json['available_online'] as bool? ?? true, clinicAffiliation: json['clinic_affiliation'] as String?, bio: json['bio'] as String? ?? '', + practitionerType: parsedType, + hasPsychologistCertificate: hasCert, + isVolunteerRole: isVol, ); } @@ -51,11 +141,22 @@ class PsychologistModel extends PsychologistEntity { 'review_count': reviewCount, 'experience_years': experienceYears, 'specialties': specialties, - 'fee_per_session': feePerSession, + 'patient_review_tags': patientReviewTags, + 'patient_reviews': patientReviews, + 'review_tags': reviewTags.map((e) => e.toJson()).toList(), + 'testimonials': testimonials.map((e) => e.toJson()).toList(), + 'patient_stats': patientStats.toJson(), + 'certifications': certifications, + 'certificate_items': certificateItems.map((e) => e.toJson()).toList(), + 'coins_per_session': coinsPerSession, + 'fee_per_session': coinsPerSession, 'duration_minutes': durationMinutes, 'available_online': availableOnline, 'clinic_affiliation': clinicAffiliation, 'bio': bio, + 'practitioner_type': practitionerType.name, + 'has_psychologist_certificate': hasPsychologistCertificate, + 'is_volunteer': isVolunteer, }; } } diff --git a/apps/eva_mobile/lib/features/care/domain/entities/consultation_session_entity.dart b/apps/eva_mobile/lib/features/care/domain/entities/consultation_session_entity.dart index 11f86d0..949f034 100644 --- a/apps/eva_mobile/lib/features/care/domain/entities/consultation_session_entity.dart +++ b/apps/eva_mobile/lib/features/care/domain/entities/consultation_session_entity.dart @@ -23,6 +23,13 @@ class ConsultationSessionEntity { final String? locationName; final String? notes; final String? diagnosisSummary; + final int sessionNumber; + final List adviceList; + final List actionItems; + final String? moodBefore; + final String? moodAfter; + final int durationMinutes; + final String? sessionSummary; const ConsultationSessionEntity({ required this.id, @@ -34,8 +41,56 @@ class ConsultationSessionEntity { this.locationName, this.notes, this.diagnosisSummary, + this.sessionNumber = 1, + this.adviceList = const [], + this.actionItems = const [], + this.moodBefore, + this.moodAfter, + this.durationMinutes = 50, + this.sessionSummary, }); bool get isUpcoming => status == ConsultationStatus.upcoming; bool get isCompleted => status == ConsultationStatus.completed; + bool get isCancelled => status == ConsultationStatus.cancelled; + bool get isOnlineVideo => type == ConsultationType.onlineVideo; + bool get isOfflineClinic => type == ConsultationType.offlineClinic; + + ConsultationSessionEntity copyWith({ + String? id, + PsychologistEntity? psychologist, + DateTime? sessionDateTime, + ConsultationStatus? status, + ConsultationType? type, + String? meetingUrl, + String? locationName, + String? notes, + String? diagnosisSummary, + int? sessionNumber, + List? adviceList, + List? actionItems, + String? moodBefore, + String? moodAfter, + int? durationMinutes, + String? sessionSummary, + }) { + return ConsultationSessionEntity( + id: id ?? this.id, + psychologist: psychologist ?? this.psychologist, + sessionDateTime: sessionDateTime ?? this.sessionDateTime, + status: status ?? this.status, + type: type ?? this.type, + meetingUrl: meetingUrl ?? this.meetingUrl, + locationName: locationName ?? this.locationName, + notes: notes ?? this.notes, + diagnosisSummary: diagnosisSummary ?? this.diagnosisSummary, + sessionNumber: sessionNumber ?? this.sessionNumber, + adviceList: adviceList ?? this.adviceList, + actionItems: actionItems ?? this.actionItems, + moodBefore: moodBefore ?? this.moodBefore, + moodAfter: moodAfter ?? this.moodAfter, + durationMinutes: durationMinutes ?? this.durationMinutes, + sessionSummary: sessionSummary ?? this.sessionSummary, + ); + } } diff --git a/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart b/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart index cfa3e49..167d32b 100644 --- a/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart +++ b/apps/eva_mobile/lib/features/care/domain/entities/psychologist_entity.dart @@ -1,21 +1,195 @@ import 'package:flutter/foundation.dart'; +enum PractitionerType { + professional, // Psikolog Profesional Independen + freelance, // Psikolog Freelance + volunteer, // Volunteer / Relawan Konselor Sebaya +} + +enum ReviewSentiment { + positive, + negative, +} + +@immutable +class PatientReviewTag { + final String label; + final ReviewSentiment sentiment; + final DateTime? createdAt; + + const PatientReviewTag({ + required this.label, + this.sentiment = ReviewSentiment.positive, + this.createdAt, + }); + + bool get isPositive => sentiment == ReviewSentiment.positive; + bool get isNegative => sentiment == ReviewSentiment.negative; + + factory PatientReviewTag.fromJson(Map json) { + return PatientReviewTag( + label: json['label'] as String? ?? '', + sentiment: (json['sentiment'] as String?) == 'negative' + ? ReviewSentiment.negative + : ReviewSentiment.positive, + createdAt: json['created_at'] != null ? DateTime.tryParse(json['created_at'] as String) : null, + ); + } + + Map toJson() => { + 'label': label, + 'sentiment': sentiment == ReviewSentiment.negative ? 'negative' : 'positive', + if (createdAt != null) 'created_at': createdAt!.toIso8601String(), + }; +} + +@immutable +class PatientReviewItem { + final String id; + final String patientName; + final ReviewSentiment sentiment; + final String label; // ulasan singkat / tag label + final String description; // deskripsi lengkap + final double rating; + final String timeAgo; + final DateTime? date; + + const PatientReviewItem({ + required this.id, + this.patientName = 'Pasien Terverifikasi (Anonim)', + this.sentiment = ReviewSentiment.positive, + required this.label, + required this.description, + this.rating = 5.0, + this.timeAgo = 'Baru saja', + this.date, + }); + + bool get isPositive => sentiment == ReviewSentiment.positive; + bool get isNegative => sentiment == ReviewSentiment.negative; + + factory PatientReviewItem.fromJson(Map json) { + return PatientReviewItem( + id: json['id'] as String? ?? '', + patientName: json['patient_name'] as String? ?? 'Pasien Terverifikasi (Anonim)', + sentiment: (json['sentiment'] as String?) == 'negative' + ? ReviewSentiment.negative + : ReviewSentiment.positive, + label: json['label'] as String? ?? '', + description: json['description'] as String? ?? '', + rating: (json['rating'] as num?)?.toDouble() ?? 5.0, + timeAgo: json['time_ago'] as String? ?? 'Baru saja', + date: json['date'] != null ? DateTime.tryParse(json['date'] as String) : null, + ); + } + + Map toJson() => { + 'id': id, + 'patient_name': patientName, + 'sentiment': sentiment == ReviewSentiment.negative ? 'negative' : 'positive', + 'label': label, + 'description': description, + 'rating': rating, + 'time_ago': timeAgo, + if (date != null) 'date': date!.toIso8601String(), + }; +} + +@immutable +class PatientStatsEntity { + final int totalPatientsTreated; + final int totalSessionsCompleted; + final double satisfactionRate; // e.g. 98.4% + final double positiveReviewRate; // e.g. 96.0% + final double recoveryRate; // e.g. 93.5% + final double avgRecoverySessions; // e.g. 3.8 sesi + final String recoveryDescription; + + const PatientStatsEntity({ + this.totalPatientsTreated = 180, + this.totalSessionsCompleted = 320, + this.satisfactionRate = 98.0, + this.positiveReviewRate = 96.0, + this.recoveryRate = 93.0, + this.avgRecoverySessions = 3.6, + this.recoveryDescription = 'Melaporkan perbaikan emosi & penurunan kecemasan', + }); + + double get negativeReviewRate => (100.0 - positiveReviewRate).clamp(0.0, 100.0); + + factory PatientStatsEntity.fromJson(Map json) { + return PatientStatsEntity( + totalPatientsTreated: (json['total_patients_treated'] as num?)?.toInt() ?? 180, + totalSessionsCompleted: (json['total_sessions_completed'] as num?)?.toInt() ?? 320, + satisfactionRate: (json['satisfaction_rate'] as num?)?.toDouble() ?? 98.0, + positiveReviewRate: (json['positive_review_rate'] as num?)?.toDouble() ?? 96.0, + recoveryRate: (json['recovery_rate'] as num?)?.toDouble() ?? 93.0, + avgRecoverySessions: (json['avg_recovery_sessions'] as num?)?.toDouble() ?? 3.6, + recoveryDescription: json['recovery_description'] as String? ?? + 'Melaporkan perbaikan emosi & penurunan kecemasan', + ); + } + + Map toJson() => { + 'total_patients_treated': totalPatientsTreated, + 'total_sessions_completed': totalSessionsCompleted, + 'satisfaction_rate': satisfactionRate, + 'positive_review_rate': positiveReviewRate, + 'recovery_rate': recoveryRate, + 'avg_recovery_sessions': avgRecoverySessions, + 'recovery_description': recoveryDescription, + }; +} + +@immutable +class CertificateItem { + final String title; + final String imageUrl; + + const CertificateItem({ + required this.title, + required this.imageUrl, + }); + + factory CertificateItem.fromJson(Map json) { + return CertificateItem( + title: json['title'] as String? ?? '', + imageUrl: json['image_url'] as String? ?? '', + ); + } + + Map toJson() => { + 'title': title, + 'image_url': imageUrl, + }; +} + @immutable class PsychologistEntity { final String id; final String name; final String title; - final String registrationNumber; // STR or SIPP + final String registrationNumber; // STR or SIPP or Sertifikasi final String avatarUrl; final double rating; final int reviewCount; final int experienceYears; final List specialties; - final int feePerSession; // in IDR + final List patientReviewTags; + final List patientReviews; + final List reviewTags; + final List testimonials; + final PatientStatsEntity patientStats; + final List certifications; + final List certificateItems; + final int coinsPerSession; // Coin charge per session (0 = Gratis) final int durationMinutes; final bool availableOnline; final String? clinicAffiliation; final String bio; + final PractitionerType practitionerType; + final bool hasPsychologistCertificate; + final bool isVolunteerRole; const PsychologistEntity({ required this.id, @@ -27,13 +201,116 @@ class PsychologistEntity { required this.reviewCount, required this.experienceYears, required this.specialties, - required this.feePerSession, + this.patientReviewTags = const [], + this.patientReviews = const [], + this.reviewTags = const [], + this.testimonials = const [], + this.patientStats = const PatientStatsEntity(), + this.certifications = const [], + this.certificateItems = const [], + this.coinsPerSession = 50, this.durationMinutes = 50, this.availableOnline = true, this.clinicAffiliation, required this.bio, + this.practitionerType = PractitionerType.professional, + this.hasPsychologistCertificate = true, + this.isVolunteerRole = false, }); + /// Effective certifications falling back to registration credentials if empty + List get effectiveCertifications { + if (certifications.isNotEmpty) return certifications; + final list = []; + if (hasPsychologistCertificate) { + list.add('Surat Izin Praktik Psikologi ($registrationNumber)'); + list.add('Magister Profesi Psikologi Klinis (S2)'); + list.add('Sertifikasi Kode Etik Profesi HIMPSI'); + } + if (isVolunteer) { + list.add('Sertifikat Psychological First Aid (PFA)'); + list.add('Pelatihan Pendampingan Konseling Sebaya'); + } + return list; + } + + /// Effective certificate items with photo URLs and clean titles + List get effectiveCertificateItems { + if (certificateItems.isNotEmpty) return certificateItems; + return effectiveCertifications.map((title) { + final lower = title.toLowerCase(); + String url; + if (lower.contains('sipp')) { + url = 'https://images.unsplash.com/photo-1614036417651-efe5912149d8?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('magister') || lower.contains('s2')) { + url = 'https://images.unsplash.com/photo-1659080928170-b9924d616f04?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('cbt') || lower.contains('sfbt') || lower.contains('mbsr')) { + url = 'https://images.unsplash.com/photo-1659080907100-23f0dac0fe27?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('pfa') || lower.contains('first aid')) { + url = 'https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('peer') || lower.contains('sebaya') || lower.contains('safe space')) { + url = 'https://images.unsplash.com/photo-1450133064473-71024230f91b?w=800&auto=format&fit=crop&q=80'; + } else { + url = 'https://images.unsplash.com/photo-1523240795612-9a054b0db644?w=800&auto=format&fit=crop&q=80'; + } + return CertificateItem(title: title, imageUrl: url); + }).toList(); + } + + /// Effective review tags falling back to patientReviewTags if reviewTags is empty + List get effectiveReviewTags { + if (reviewTags.isNotEmpty) return reviewTags; + return patientReviewTags + .map((tag) => PatientReviewTag(label: tag, sentiment: ReviewSentiment.positive)) + .toList(); + } + + /// Effective testimonials falling back to patientReviews if testimonials is empty + List get effectiveTestimonials { + if (testimonials.isNotEmpty) return testimonials; + return patientReviews.asMap().entries.map((entry) { + return PatientReviewItem( + id: 'rev_${entry.key}', + patientName: 'Pasien Terverifikasi (Anonim)', + sentiment: ReviewSentiment.positive, + label: 'Konseling Membantu', + description: entry.value, + rating: 5.0, + timeAgo: '${entry.key + 1} minggu lalu', + ); + }).toList(); + } + + /// Backwards compatibility getter + int get feePerSession => coinsPerSession; + + bool get isVolunteer => isVolunteerRole || practitionerType == PractitionerType.volunteer; + bool get isFreelance => practitionerType == PractitionerType.freelance; + bool get isProfessional => practitionerType == PractitionerType.professional; + bool get hasBothBadges => hasPsychologistCertificate && isVolunteer; + + String get badgeHeaderTitle { + if (hasBothBadges) { + return 'Relawan & Profesional'; + } else if (hasPsychologistCertificate) { + return 'Profesional'; + } else if (isVolunteer) { + return 'Relawan'; + } else { + return 'Profesional'; + } + } + + String get typeLabel { + if (hasBothBadges) { + return 'Relawan & Profesional'; + } else if (isVolunteer) { + return 'Relawan'; + } else { + return 'Profesional'; + } + } + bool get isPsychiatrist => title.toLowerCase().contains('sp.kj') || title.toLowerCase().contains('kedokteran jiwa') || diff --git a/apps/eva_mobile/lib/features/care/presentation/providers/care_provider.dart b/apps/eva_mobile/lib/features/care/presentation/providers/care_provider.dart index 78d207d..535aeb0 100644 --- a/apps/eva_mobile/lib/features/care/presentation/providers/care_provider.dart +++ b/apps/eva_mobile/lib/features/care/presentation/providers/care_provider.dart @@ -130,6 +130,44 @@ class ConsultationSessionsNotifier return false; } } + + Future completeSession( + String sessionId, { + List? adviceList, + List? actionItems, + String? sessionSummary, + String? moodAfter, + }) async { + final current = state.value ?? []; + state = AsyncValue.data( + current.map((s) { + if (s.id == sessionId) { + return s.copyWith( + status: ConsultationStatus.completed, + adviceList: adviceList ?? + (s.adviceList.isNotEmpty + ? s.adviceList + : const [ + 'Lakukan teknik relaksasi napas dalam saat merasa cemas.', + 'Catat perubahan suasana hati di Jurnal Eva.', + 'Jadwalkan sesi follow-up dalam 2 minggu ke depan.', + ]), + actionItems: actionItems ?? + (s.actionItems.isNotEmpty + ? s.actionItems + : const [ + 'Praktikkan latihan mindfulness 5 menit setiap pagi.', + ]), + sessionSummary: sessionSummary ?? + s.sessionSummary ?? + 'Sesi konsultasi tatap muka online selesai dilaksanakan dengan hasil positif.', + moodAfter: moodAfter ?? 'Tenang & Lebih Lega (Skala 4/10)', + ); + } + return s; + }).toList(), + ); + } } final consultationSessionsProvider = StateNotifierProvider< diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/care_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/care_screen.dart index 92522b6..f961468 100644 --- a/apps/eva_mobile/lib/features/care/presentation/screens/care_screen.dart +++ b/apps/eva_mobile/lib/features/care/presentation/screens/care_screen.dart @@ -7,15 +7,18 @@ import '../../../../theme/app_colors.dart'; import '../../../auth/presentation/screens/profile_screen.dart'; import '../../../home/presentation/widgets/profile_dropdown_modal.dart'; import '../providers/care_provider.dart'; -import '../widgets/community_banner_card.dart'; -import '../widgets/community_paywall_sheet.dart'; +import '../widgets/care_quick_actions.dart'; import '../widgets/psychologist_card.dart'; -import '../widgets/clinic_card.dart'; -import '../widgets/consultation_history_card.dart'; import '../widgets/booking_consultation_sheet.dart'; +import 'psychologist_detail_screen.dart'; class CareScreen extends ConsumerStatefulWidget { - const CareScreen({super.key}); + final GlobalKey? firstPsychologistCardKey; + + const CareScreen({ + super.key, + this.firstPsychologistCardKey, + }); @override ConsumerState createState() => CareScreenState(); @@ -106,12 +109,16 @@ class CareScreenState extends ConsumerState { }, onEmergencyContactsTap: () { _removeProfileOverlay(); - Navigator.pushNamed(context, '/emergency-contacts'); + Navigator.pushNamed(context, '/emergency_contacts'); }, onSettingsTap: () { _removeProfileOverlay(); Navigator.pushNamed(context, '/settings'); }, + onCoinTap: () { + _removeProfileOverlay(); + Navigator.pushNamed(context, '/coin_purchase'); + }, onLogoutTap: () { _removeProfileOverlay(); Navigator.pushReplacementNamed(context, '/login'); @@ -126,42 +133,10 @@ class CareScreenState extends ConsumerState { Overlay.of(context).insert(_profileOverlayEntry!); } - void _handleOpenDiscord() { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), - title: Row( - children: [ - const Icon(Icons.forum, color: Color(0xFF5865F2)), - const SizedBox(width: 8), - Text( - 'Discord Community', - style: GoogleFonts.plusJakartaSans(fontSize: 16, fontWeight: FontWeight.w700), - ), - ], - ), - content: Text( - 'Tautan invite server: https://discord.gg/evasafespace\n\nPastikan akun Discord Anda sudah masuk untuk menikmati channel obrolan & role VIP.', - style: GoogleFonts.plusJakartaSans(fontSize: 13, color: AppColors.textPrimary, height: 1.4), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text('Tutup', style: GoogleFonts.plusJakartaSans(fontWeight: FontWeight.w700)), - ), - ], - ), - ); - } - @override Widget build(BuildContext context) { final activeFilter = ref.watch(careCategoryFilterProvider); - final communityStatusAsync = ref.watch(communityStatusProvider); - final sessionsAsync = ref.watch(consultationSessionsProvider); final psychologistsAsync = ref.watch(psychologistsListProvider); - final clinicsAsync = ref.watch(clinicsListProvider); return Scaffold( backgroundColor: AppColors.background, @@ -170,14 +145,12 @@ class CareScreenState extends ConsumerState { color: AppColors.primary, onRefresh: () async { ref.invalidate(psychologistsListProvider); - ref.invalidate(clinicsListProvider); - ref.read(communityStatusProvider.notifier).loadStatus(); ref.read(consultationSessionsProvider.notifier).loadSessions(); }, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ - // 1. Custom Header & Profile Avatar + // 1. Header with Profile Dropdown SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), @@ -188,7 +161,7 @@ class CareScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Layanan & Komunitas', + 'Layanan Konseling', style: GoogleFonts.plusJakartaSans( fontSize: 22, fontWeight: FontWeight.w800, @@ -197,7 +170,7 @@ class CareScreenState extends ConsumerState { ), const SizedBox(height: 2), Text( - 'Bantuan klinis profesional & safe space', + 'Konseling psikolog berlisensi & relawan sebaya', style: GoogleFonts.plusJakartaSans( fontSize: 12.5, color: AppColors.textSecondary, @@ -240,99 +213,24 @@ class CareScreenState extends ConsumerState { ), ), - // 2. Banner Komunitas Discord (Dynamic State) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: communityStatusAsync.when( - data: (status) => CommunityBannerCard( - status: status, - onOpenPaywall: () => CommunityPaywallSheet.show(context), - onOpenDiscord: _handleOpenDiscord, - ), - loading: () => Container( - height: 140, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), - child: const Center( - child: CircularProgressIndicator(color: AppColors.primary), - ), - ), - error: (error, stack) => const SizedBox(), - ), - ), + // 2. Quick Action Hub (Jadwal, Riwayat, Koin, Panduan) + const SliverToBoxAdapter( + child: CareQuickActions(), ), - // 3. Section Sesi & Riwayat Konsultasi (Jika ada) - sessionsAsync.when( - data: (sessions) { - if (sessions.isEmpty) return const SliverToBoxAdapter(child: SizedBox()); - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Jadwal & Riwayat Konsultasi', - style: GoogleFonts.plusJakartaSans( - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - Text( - '${sessions.length} Sesi', - style: GoogleFonts.plusJakartaSans( - fontSize: 12, - color: AppColors.textSecondary, - ), - ), - ], - ), - const SizedBox(height: 10), - ConsultationHistoryCard( - session: sessions.first, - onActionTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Membuka sesi: ${sessions.first.meetingUrl ?? "Klinik"}', - ), - backgroundColor: AppColors.primary, - ), - ); - }, - onRebookTap: () { - BookingConsultationSheet.show( - context, - sessions.first.psychologist, - ); - }, - ), - ], - ), - ), - ); - }, - loading: () => const SliverToBoxAdapter(child: SizedBox()), - error: (error, stack) => const SliverToBoxAdapter(child: SizedBox()), + const SliverToBoxAdapter( + child: SizedBox(height: 10), ), - // 4. Search Bar & Filter Chips + // 3. Search Bar & Filter Chips (Psikolog, Relawan) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + padding: const EdgeInsets.fromLTRB(20, 8, 20, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Temukan Tenaga Profesional & Faskes', + 'Temukan Psikolog & Relawan', style: GoogleFonts.plusJakartaSans( fontSize: 16, fontWeight: FontWeight.w700, @@ -348,7 +246,7 @@ class CareScreenState extends ConsumerState { ref.read(careSearchQueryProvider.notifier).state = val; }, decoration: InputDecoration( - hintText: 'Cari nama psikolog, spesialisasi, atau klinik...', + hintText: 'Cari nama konselor, ulasan pasien, atau isu...', hintStyle: GoogleFonts.plusJakartaSans( fontSize: 13, color: AppColors.textLight, @@ -382,18 +280,17 @@ class CareScreenState extends ConsumerState { ), const SizedBox(height: 12), - // Filter Chips + // Filter Chips: Semua, Psikolog, Relawan SingleChildScrollView( scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), child: Row( children: [ _buildFilterChip('Semua', 'all', activeFilter), const SizedBox(width: 8), _buildFilterChip('Psikolog', 'psychologist', activeFilter), const SizedBox(width: 8), - _buildFilterChip('Psikiater', 'psychiatrist', activeFilter), - const SizedBox(width: 8), - _buildFilterChip('Klinik Mitra', 'clinic', activeFilter), + _buildFilterChip('Relawan', 'volunteer', activeFilter), ], ), ), @@ -402,87 +299,55 @@ class CareScreenState extends ConsumerState { ), ), - // 5. Main Directory List (Psychologists or Clinics) - if (activeFilter == 'clinic') ...[ - clinicsAsync.when( - data: (clinics) { - if (clinics.isEmpty) { - return _buildEmptyState('Tidak ada klinik mitra yang cocok'); - } - return SliverPadding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 100), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final clinic = clinics[index]; - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: ClinicCard( - clinic: clinic, - onDetailsTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Menghubungi ${clinic.name} (${clinic.phone})'), - backgroundColor: AppColors.primary, + // 4. Main Directory List of Psychologists / Volunteers + psychologistsAsync.when( + data: (psychologists) { + if (psychologists.isEmpty) { + return _buildEmptyState('Tidak ada psikolog atau relawan yang cocok'); + } + return SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 100), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final psychologist = psychologists[index]; + return Padding( + key: index == 0 ? widget.firstPsychologistCardKey : null, + padding: const EdgeInsets.only(bottom: 14), + child: PsychologistCard( + psychologist: psychologist, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PsychologistDetailScreen( + psychologist: psychologist, ), - ); - }, - ), - ); - }, - childCount: clinics.length, - ), + ), + ); + }, + onBookTap: () { + BookingConsultationSheet.show(context, psychologist); + }, + ), + ); + }, + childCount: psychologists.length, ), - ); - }, - loading: () => const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator(color: AppColors.primary)), ), - ), - error: (err, _) => SliverToBoxAdapter( - child: Center(child: Text('Gagal memuat klinik: $err')), + ); + }, + loading: () => SliverToBoxAdapter( + key: widget.firstPsychologistCardKey, + child: const Padding( + padding: EdgeInsets.all(40), + child: Center(child: CircularProgressIndicator(color: AppColors.primary)), ), ), - ] else ...[ - psychologistsAsync.when( - data: (psychologists) { - if (psychologists.isEmpty) { - return _buildEmptyState('Tidak ada psikolog atau psikiater yang cocok'); - } - return SliverPadding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 100), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final psychologist = psychologists[index]; - return Padding( - padding: const EdgeInsets.only(bottom: 14), - child: PsychologistCard( - psychologist: psychologist, - onBookTap: () { - BookingConsultationSheet.show(context, psychologist); - }, - ), - ); - }, - childCount: psychologists.length, - ), - ), - ); - }, - loading: () => const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator(color: AppColors.primary)), - ), - ), - error: (err, _) => SliverToBoxAdapter( - child: Center(child: Text('Gagal memuat psikolog: $err')), - ), + error: (err, _) => SliverToBoxAdapter( + child: Center(child: Text('Gagal memuat konselor: $err')), ), - ], + ), ], ), ), @@ -495,13 +360,15 @@ class CareScreenState extends ConsumerState { return ChoiceChip( label: Text(label), selected: isSelected, - onSelected: (_) { - ref.read(careCategoryFilterProvider.notifier).state = key; + onSelected: (selected) { + ref.read(careCategoryFilterProvider.notifier).state = + selected ? key : 'all'; }, selectedColor: AppColors.primary, + checkmarkColor: Colors.white, backgroundColor: Colors.white, labelStyle: GoogleFonts.plusJakartaSans( - fontSize: 12.5, + fontSize: 12, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, color: isSelected ? Colors.white : AppColors.textPrimary, ), @@ -517,21 +384,23 @@ class CareScreenState extends ConsumerState { Widget _buildEmptyState(String message) { return SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20), - child: Column( - children: [ - Icon(Icons.search_off_rounded, size: 48, color: Colors.grey.shade400), - const SizedBox(height: 12), - Text( - message, - style: GoogleFonts.plusJakartaSans( - fontSize: 14, - color: AppColors.textSecondary, - fontWeight: FontWeight.w500, + padding: const EdgeInsets.all(32), + child: Center( + child: Column( + children: [ + const Icon(Icons.search_off_rounded, size: 48, color: Color(0xFF94A3B8)), + const SizedBox(height: 12), + Text( + message, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - ], + ], + ), ), ), ); diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/consultation_history_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_history_screen.dart new file mode 100644 index 0000000..8b07364 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_history_screen.dart @@ -0,0 +1,587 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../providers/care_provider.dart'; +import '../widgets/booking_consultation_sheet.dart'; +import 'consultation_session_logs_screen.dart'; +import 'psychologist_detail_screen.dart'; + +/// Screen displaying the history of psychologists hired and comprehensive per-session clinical logs & advice. +class ConsultationHistoryScreen extends ConsumerStatefulWidget { + const ConsultationHistoryScreen({super.key}); + + @override + ConsumerState createState() => + _ConsultationHistoryScreenState(); +} + +class _ConsultationHistoryScreenState + extends ConsumerState { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + String _formatDate(DateTime dt) { + final dayNames = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Minggu']; + final monthNames = [ + 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', + 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember' + ]; + final dayName = dayNames[dt.weekday - 1]; + final monthName = monthNames[dt.month - 1]; + return '$dayName, ${dt.day} $monthName ${dt.year}'; + } + + String _formatTime(DateTime dt) { + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + /// Opens the dedicated session logs & advice screen for the selected psychologist + void _openPsychologistLogsPage( + BuildContext context, + PsychologistEntity psychologist, + List sessions, + ) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ConsultationSessionLogsScreen( + psychologist: psychologist, + sessions: sessions, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final sessionsAsync = ref.watch(consultationSessionsProvider); + final allSessions = sessionsAsync.value ?? []; + + // Group sessions by psychologist + final Map> psychologistSessionsMap = {}; + for (final s in allSessions) { + final psyId = s.psychologist.id; + if (!psychologistSessionsMap.containsKey(psyId)) { + psychologistSessionsMap[psyId] = []; + } + psychologistSessionsMap[psyId]!.add(s); + } + + final List hiredPsychologists = psychologistSessionsMap.keys + .map((psyId) => psychologistSessionsMap[psyId]!.first.psychologist) + .toList(); + + final filteredPsychologists = hiredPsychologists.where((p) { + if (_searchQuery.isEmpty) return true; + final nameMatches = p.name.toLowerCase().contains(_searchQuery); + final titleMatches = p.title.toLowerCase().contains(_searchQuery); + final specMatches = p.specialties.any((s) => s.toLowerCase().contains(_searchQuery)); + return nameMatches || titleMatches || specMatches; + }).toList(); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + // 1. Navigation Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Riwayat Bicara & Konseling', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Daftar psikolog yang pernah disewa & catatan saran per sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + + // 2. Search Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: _searchController, + onChanged: (val) { + setState(() { + _searchQuery = val.trim().toLowerCase(); + }); + }, + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, + ), + decoration: InputDecoration( + hintText: 'Cari nama psikolog atau relawan...', + hintStyle: GoogleFonts.plusJakartaSans( + fontSize: 13, + color: AppColors.textLight, + ), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.primary, + size: 20, + ), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded, size: 18, color: AppColors.textSecondary), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + ), + ), + const SizedBox(height: 6), + + // 3. Scrollable Body + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section Title & Counter + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'PSIKOLOG & RELAWAN', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${filteredPsychologists.length}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // List of Hired Psychologists / Volunteers or Empty States + if (hiredPsychologists.isEmpty) + _buildEmptyState() + else if (filteredPsychologists.isEmpty) + _buildSearchEmptyState() + else + ...filteredPsychologists.map((psychologist) { + final sessions = psychologistSessionsMap[psychologist.id] ?? []; + return _buildHiredPsychologistCard(psychologist, sessions); + }), + + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildSearchEmptyState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + const Icon( + Icons.search_off_rounded, + size: 42, + color: Color(0xFF94A3B8), + ), + const SizedBox(height: 12), + Text( + 'Tidak Ditemukan', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Tidak ada psikolog atau relawan dengan nama "$_searchQuery"', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + child: Text( + 'Reset Pencarian', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ), + ); + } + + Widget _buildHiredPsychologistCard( + PsychologistEntity psychologist, + List sessions, + ) { + final latestSession = sessions.isNotEmpty ? sessions.first : null; + final latestAdvice = (latestSession != null && latestSession.adviceList.isNotEmpty) + ? latestSession.adviceList.first + : (latestSession?.diagnosisSummary ?? 'Konsultasi selesai dengan baik.'); + + return InkWell( + onTap: () => _openPsychologistLogsPage(context, psychologist, sessions), + borderRadius: BorderRadius.circular(18), + child: Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 24, + backgroundImage: NetworkImage(psychologist.avatarUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 14.5, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(5), + ), + child: Text( + '${sessions.length} Sesi Terlaksana', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 6), + if (latestSession != null) + Text( + 'Terakhir: ${_formatDate(latestSession.sessionDateTime).split(',')[0]}', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + color: AppColors.textLight, + ), + ), + ], + ), + ], + ), + ), + const Icon( + Icons.arrow_forward_ios_rounded, + size: 14, + color: Color(0xFF94A3B8), + ), + ], + ), + + const SizedBox(height: 12), + + // Highlight of latest advice + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF0FDF4), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFBBF7D0)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.lightbulb_outline_rounded, + size: 15, + color: Color(0xFF16A34A), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Saran Terakhir: "$latestAdvice"', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: const Color(0xFF15803D), + height: 1.35, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + const SizedBox(height: 12), + + // Action Buttons + Row( + children: [ + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textPrimary, + side: const BorderSide(color: Color(0xFFCBD5E1)), + padding: const EdgeInsets.symmetric(vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PsychologistDetailScreen( + psychologist: psychologist, + ), + ), + ); + }, + child: Text( + 'Profil Konselor', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 8), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => _openPsychologistLogsPage( + context, + psychologist, + sessions, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.assignment_rounded, size: 15), + const SizedBox(width: 6), + Text( + 'Lihat Log & Saran Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildEmptyState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Color(0xFFF1F5F9), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.forum_outlined, + size: 40, + color: Color(0xFF94A3B8), + ), + ), + const SizedBox(height: 14), + Text( + 'Belum Ada Riwayat Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + 'Setelah kamu menyelesaikan sesi konsultasi video call dengan psikolog atau relawan, catatan saran dan evaluasi klinis per sesi akan otomatis tercatat di sini.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 18), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => Navigator.pop(context), + child: Text( + 'Cari Psikolog Sekarang', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/consultation_schedule_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_schedule_screen.dart new file mode 100644 index 0000000..a96dce1 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_schedule_screen.dart @@ -0,0 +1,1009 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../providers/care_provider.dart'; +import '../widgets/clinic_location_map_card.dart'; +import '../widgets/consultation_result_bottom_sheet.dart'; +import 'psychologist_detail_screen.dart'; +import 'psychologist_video_call_screen.dart'; + +/// Full-page screen for managing consultation schedules and entering live video calls. +class ConsultationScheduleScreen extends ConsumerStatefulWidget { + const ConsultationScheduleScreen({super.key}); + + @override + ConsumerState createState() => + _ConsultationScheduleScreenState(); +} + +class _ConsultationScheduleScreenState + extends ConsumerState { + String _selectedTab = 'upcoming'; // 'upcoming', 'completed', 'all' + + String _formatTime(DateTime dt) { + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + String _formatDate(DateTime dt) { + final dayNames = ['Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu', 'Minggu']; + final monthNames = [ + 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', + 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember' + ]; + final dayName = dayNames[dt.weekday - 1]; + final monthName = monthNames[dt.month - 1]; + return '$dayName, ${dt.day} $monthName ${dt.year}'; + } + + bool _isSessionTimeNow(ConsultationSessionEntity session) { + if (session.type != ConsultationType.onlineVideo) return false; + + final now = DateTime.now(); + // Buka akses masuk mulai 10 menit sebelum jadwal sesi + final startTime = session.sessionDateTime.subtract(const Duration(minutes: 10)); + // Sesi berakhir setelah durasi + 15 menit grace period + final endTime = session.sessionDateTime.add(Duration(minutes: session.durationMinutes + 15)); + + return now.isAfter(startTime) && now.isBefore(endTime); + } + + void _showNotYetSessionTimeDialog(ConsultationSessionEntity session) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.lock_clock_rounded, + color: Color(0xFFD97706), + size: 22, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Belum Waktunya Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ruang video call bersama ${session.psychologist.name} belum dapat dimasuki.', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + const Icon( + Icons.access_time_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Jadwal: ${_formatDate(session.sessionDateTime)} • ${_formatTime(session.sessionDateTime)} WIB', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Text( + 'Pintu sesi video call akan otomatis dibuka 10 menit sebelum jam sesi dimulai. Silakan kembali saat jam konsultasi telah tiba.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + ], + ), + actions: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => Navigator.pop(ctx), + child: Text( + 'Mengerti', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } + + void _showSessionResult(ConsultationSessionEntity session) { + ConsultationResultBottomSheet.show(context, session); + } + + void _launchVideoCall(ConsultationSessionEntity session) { + if (!_isSessionTimeNow(session)) { + _showNotYetSessionTimeDialog(session); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PsychologistVideoCallScreen(session: session), + ), + ); + } + + @override + Widget build(BuildContext context) { + final sessionsAsync = ref.watch(consultationSessionsProvider); + final allSessions = sessionsAsync.value ?? []; + + final upcomingSessions = allSessions + .where((s) => s.status == ConsultationStatus.upcoming) + .toList(); + final completedSessions = allSessions + .where((s) => s.status == ConsultationStatus.completed) + .toList(); + + List displayedSessions; + if (_selectedTab == 'upcoming') { + displayedSessions = upcomingSessions; + } else if (_selectedTab == 'completed') { + displayedSessions = completedSessions; + } else { + displayedSessions = allSessions; + } + + // Identify ready or imminent session + final readySession = upcomingSessions.isNotEmpty ? upcomingSessions.first : null; + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + // 1. Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Jadwal Sesi Mendatang', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Kelola janji konsultasi aktif & riwayat jadwalmu', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + + // 2. Scrollable Body + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Highlight Banner: Ready to Join Video Call Banner + if (readySession != null) ...[ + _buildReadyVideoCallBanner(readySession), + const SizedBox(height: 20), + ], + + // Filter Tabs (Sesi Aktif, Selesai, Semua) + _buildFilterTabs( + upcomingCount: upcomingSessions.length, + completedCount: completedSessions.length, + totalCount: allSessions.length, + ), + + const SizedBox(height: 16), + + // Session List + if (displayedSessions.isEmpty) + _buildEmptyState() + else + ...displayedSessions.map((session) { + return _buildSessionCard(session); + }), + + const SizedBox(height: 24), + ], + ), + ), + ), + ], + ), + ), + ); + } + + /// Special Highlight Banner for Live / Imminent Video Call or Offline Session + Widget _buildReadyVideoCallBanner(ConsultationSessionEntity session) { + final isOnline = session.type == ConsultationType.onlineVideo; + final isTimeNow = isOnline && _isSessionTimeNow(session); + + Color bgGradientStart; + Color bgGradientEnd; + Color badgeColor; + String badgeText; + IconData badgeIcon; + + if (!isOnline) { + bgGradientStart = const Color(0xFF0F766E); + bgGradientEnd = const Color(0xFF14B8A6); + badgeColor = const Color(0xFF0D9488); + badgeText = 'Sesi Tatap Muka di Klinik (Offline)'; + badgeIcon = Icons.storefront_rounded; + } else if (isTimeNow) { + bgGradientStart = AppColors.primary; + bgGradientEnd = const Color(0xFF6B71E8); + badgeColor = AppColors.success; + badgeText = 'Waktunya Sesi Konsultasi!'; + badgeIcon = Icons.videocam_rounded; + } else { + bgGradientStart = const Color(0xFF334155); + bgGradientEnd = const Color(0xFF475569); + badgeColor = const Color(0xFFF59E0B); + badgeText = 'Jadwal Sesi Mendatang'; + badgeIcon = Icons.lock_clock_rounded; + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [bgGradientStart, bgGradientEnd], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(22), + boxShadow: [ + BoxShadow( + color: bgGradientStart.withValues(alpha: 0.3), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(badgeIcon, size: 14, color: Colors.white), + const SizedBox(width: 5), + Text( + badgeText, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ], + ), + ), + Text( + 'Sesi #${session.sessionNumber}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: Colors.white70, + ), + ), + ], + ), + const SizedBox(height: 14), + + // Psychologist Info + Row( + children: [ + Stack( + children: [ + CircleAvatar( + radius: 26, + backgroundImage: NetworkImage(session.psychologist.avatarUrl), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: isTimeNow || !isOnline + ? AppColors.success + : const Color(0xFFF59E0B), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + session.psychologist.typeLabel, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.secondary, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.access_time_filled_rounded, size: 15, color: Colors.white), + const SizedBox(width: 8), + Expanded( + child: Text( + !isOnline + ? '${_formatDate(session.sessionDateTime)} • ${_formatTime(session.sessionDateTime)} WIB (${session.locationName ?? 'Klinik Mitra'})' + : isTimeNow + ? '${_formatDate(session.sessionDateTime)} • ${_formatTime(session.sessionDateTime)} WIB (Sedang Berlangsung)' + : '${_formatDate(session.sessionDateTime)} • ${_formatTime(session.sessionDateTime)} WIB (Dibuka 10 mnt sebelum sesi)', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Action Button based on Type and Time validity + if (!isOnline) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF0F766E), + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: () { + ClinicLocationMapCard.launchRandomGoogleMaps( + context: context, + query: session.locationName ?? + session.psychologist.clinicAffiliation, + ); + }, + icon: const Icon(Icons.map_rounded, size: 18), + label: Text( + 'Buka di Google Maps', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + ) + else if (isTimeNow) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: () => _launchVideoCall(session), + icon: const Icon(Icons.video_call_rounded, size: 20), + label: Text( + 'Masuk Video Call Sekarang', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + ) + else + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white.withValues(alpha: 0.16), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: Colors.white.withValues(alpha: 0.25)), + ), + ), + onPressed: () => _showNotYetSessionTimeDialog(session), + icon: const Icon(Icons.lock_clock_rounded, size: 19), + label: Text( + 'Belum Waktunya Masuk Video Call', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildFilterTabs({ + required int upcomingCount, + required int completedCount, + required int totalCount, + }) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Expanded( + child: _buildTabButton( + title: 'Sesi Aktif ($upcomingCount)', + keyTab: 'upcoming', + ), + ), + Expanded( + child: _buildTabButton( + title: 'Selesai ($completedCount)', + keyTab: 'completed', + ), + ), + Expanded( + child: _buildTabButton( + title: 'Semua ($totalCount)', + keyTab: 'all', + ), + ), + ], + ), + ); + } + + Widget _buildTabButton({ + required String title, + required String keyTab, + }) { + final isActive = _selectedTab == keyTab; + return GestureDetector( + onTap: () => setState(() => _selectedTab = keyTab), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isActive ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(10), + boxShadow: isActive + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Center( + child: Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: isActive ? FontWeight.w800 : FontWeight.w600, + color: isActive ? AppColors.textPrimary : AppColors.textSecondary, + ), + ), + ), + ), + ); + } + + Widget _buildSessionCard(ConsultationSessionEntity session) { + final isUpcoming = session.status == ConsultationStatus.upcoming; + final isCompleted = session.status == ConsultationStatus.completed; + + return Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: isUpcoming ? AppColors.primaryContainer : const Color(0xFFE2E8F0), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row + Row( + children: [ + CircleAvatar( + radius: 22, + backgroundImage: NetworkImage(session.psychologist.avatarUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + session.psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3.5), + decoration: BoxDecoration( + color: isUpcoming + ? (session.type == ConsultationType.offlineClinic + ? const Color(0xFFCCFBF1) + : AppColors.successContainer) + : isCompleted + ? const Color(0xFFEEF2FF) + : const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + isUpcoming + ? (session.type == ConsultationType.offlineClinic + ? 'Tatap Muka' + : 'Online Video') + : isCompleted + ? 'Selesai' + : 'Dibatalkan', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: isUpcoming + ? (session.type == ConsultationType.offlineClinic + ? const Color(0xFF0F766E) + : AppColors.successDark) + : isCompleted + ? AppColors.primary + : AppColors.textSecondary, + ), + ), + ), + ], + ), + + const SizedBox(height: 12), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 10), + + // Date & Time + Row( + children: [ + const Icon( + Icons.calendar_today_rounded, + size: 14, + color: AppColors.primary, + ), + const SizedBox(width: 6), + Text( + _formatDate(session.sessionDateTime), + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(width: 8), + Text( + '• ${_formatTime(session.sessionDateTime)} WIB', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + + // Complaints / Notes + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.format_quote_rounded, + size: 16, + color: Color(0xFF94A3B8), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Keluhan: "${session.notes}"', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontStyle: FontStyle.italic, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ), + ], + ), + ), + ], + + // Offline Clinic Map Preview Card + if (session.type == ConsultationType.offlineClinic) ...[ + const SizedBox(height: 10), + ClinicLocationMapCard( + clinicName: session.locationName ?? + session.psychologist.clinicAffiliation ?? + 'Klinik Psikologi Sahabat Jiwa', + address: 'Jl. Kemanggisan Raya No. 45, Palmerah, Jakarta Barat', + ), + ], + + const SizedBox(height: 12), + + // Actions Row + Row( + children: [ + // Detail button + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.textPrimary, + side: const BorderSide(color: Color(0xFFCBD5E1)), + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PsychologistDetailScreen( + psychologist: session.psychologist, + ), + ), + ); + }, + child: Text( + 'Detail Konselor', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + + const SizedBox(width: 8), + + // Action Button based on Type, Time & Status + if (isUpcoming) ...[ + if (session.type == ConsultationType.offlineClinic) + Expanded( + flex: 2, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0D9488), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () { + ClinicLocationMapCard.launchRandomGoogleMaps( + context: context, + query: session.locationName ?? + session.psychologist.clinicAffiliation, + ); + }, + icon: const Icon(Icons.map_rounded, size: 16), + label: Text( + 'Buka di Maps', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ) + else if (_isSessionTimeNow(session)) + Expanded( + flex: 2, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => _launchVideoCall(session), + icon: const Icon(Icons.videocam_rounded, size: 16), + label: Text( + 'Masuk Video Call', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ) + else + Expanded( + flex: 2, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF1F5F9), + foregroundColor: AppColors.textSecondary, + padding: const EdgeInsets.symmetric(vertical: 10), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: const BorderSide(color: Color(0xFFCBD5E1)), + ), + ), + onPressed: () => _showNotYetSessionTimeDialog(session), + icon: const Icon(Icons.lock_outline_rounded, size: 15), + label: Text( + 'Belum Waktunya', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + ), + ), + ] else if (isCompleted) ...[ + Expanded( + flex: 2, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => _showSessionResult(session), + icon: const Icon(Icons.assignment_turned_in_rounded, size: 16), + label: Text( + 'Lihat Saran Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ], + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Color(0xFFF1F5F9), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.calendar_month_outlined, + size: 40, + color: Color(0xFF94A3B8), + ), + ), + const SizedBox(height: 14), + Text( + 'Tidak Ada Jadwal Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + Text( + 'Kamu belum memiliki jadwal sesi pada kategori ini. Silakan cari psikolog untuk menjadwalkan konsultasi baru.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 18), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => Navigator.pop(context), + child: Text( + 'Cari Psikolog Sekarang', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/consultation_session_logs_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_session_logs_screen.dart new file mode 100644 index 0000000..0b1b96c --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/consultation_session_logs_screen.dart @@ -0,0 +1,917 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../providers/care_provider.dart'; +import '../widgets/booking_consultation_sheet.dart'; +import 'psychologist_detail_screen.dart'; + +/// Dedicated screen for displaying detailed clinical session logs, evaluations, +/// and actionable recommendations provided by a psychologist. +class ConsultationSessionLogsScreen extends ConsumerStatefulWidget { + final PsychologistEntity psychologist; + final List sessions; + + const ConsultationSessionLogsScreen({ + super.key, + required this.psychologist, + required this.sessions, + }); + + @override + ConsumerState createState() => + _ConsultationSessionLogsScreenState(); +} + +class _ConsultationSessionLogsScreenState + extends ConsumerState { + DateTime? _selectedDate; // null means 'Semua Tanggal' + + String _formatDate(DateTime dt) { + final months = [ + 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', + 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember' + ]; + return '${dt.day} ${months[dt.month - 1]} ${dt.year}'; + } + + String _formatShortDate(DateTime dt) { + final months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', + 'Jul', 'Agt', 'Sep', 'Okt', 'Nov', 'Des' + ]; + return '${dt.day} ${months[dt.month - 1]} ${dt.year}'; + } + + String _formatTime(DateTime dt) { + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + @override + Widget build(BuildContext context) { + // Reactively watch sessions from provider if available, fallback to passed sessions + final sessionsAsync = ref.watch(consultationSessionsProvider); + final allSessions = sessionsAsync.value ?? []; + final psychologistSessions = allSessions + .where((s) => s.psychologist.id == widget.psychologist.id) + .toList(); + + final activeSessions = psychologistSessions.isNotEmpty + ? psychologistSessions + : widget.sessions; + + final displayedSessions = _selectedDate == null + ? activeSessions + : activeSessions + .where((s) => _isSameDay(s.sessionDateTime, _selectedDate!)) + .toList(); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + // 1. Navigation Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Log & Saran Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Catatan klinis & rekomendasi per sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + + // 2. Scrollable Body + Expanded( + child: ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + children: [ + // Psychologist Overview Card + _buildPsychologistHeaderCard(activeSessions.length), + const SizedBox(height: 14), + + // Confidentiality Note Banner + _buildConfidentialityBanner(), + const SizedBox(height: 14), + + // Date Filter Bar + if (activeSessions.isNotEmpty) ...[ + _buildDateFilterBar(activeSessions), + const SizedBox(height: 14), + ], + + // Session Logs List or Empty State + if (displayedSessions.isEmpty) + _buildEmptyState() + else + ...displayedSessions.map((session) { + return _buildSessionLogCard(session); + }), + + const SizedBox(height: 24), + ], + ), + ), + + // 3. Persistent Bottom Action Dock + _buildBottomActionDock(), + ], + ), + ), + ); + } + + /// Psychologist Summary Header Card with Profile Navigation + Widget _buildPsychologistHeaderCard(int totalSessionsCount) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PsychologistDetailScreen( + psychologist: widget.psychologist, + ), + ), + ); + }, + borderRadius: BorderRadius.circular(18), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + Stack( + children: [ + CircleAvatar( + radius: 26, + backgroundImage: NetworkImage(widget.psychologist.avatarUrl), + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + widget.psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.star_rounded, size: 14, color: Color(0xFFF59E0B)), + const SizedBox(width: 3), + Text( + '${widget.psychologist.rating}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(width: 6), + Text( + '• $totalSessionsCount Sesi Dijalani', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right_rounded, + size: 22, + color: AppColors.textLight, + ), + ], + ), + ), + ), + ); + } + + /// Informative banner emphasizing privacy and medical ethics + Widget _buildConfidentialityBanner() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.primaryContainer), + ), + child: Row( + children: [ + const Icon( + Icons.lock_outline_rounded, + size: 18, + color: AppColors.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Catatan & ringkasan evaluasi ini dilindungi kerahasiaan medis psikolog untuk memantau progres kesehatan mental Anda.', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.primary, + fontWeight: FontWeight.w600, + height: 1.35, + ), + ), + ), + ], + ), + ); + } + + /// Horizontal date filter chips for filtering session logs by date + Widget _buildDateFilterBar(List sessions) { + final uniqueDates = {}; + for (final s in sessions) { + uniqueDates.add(DateTime( + s.sessionDateTime.year, + s.sessionDateTime.month, + s.sessionDateTime.day, + )); + } + final sortedDates = uniqueDates.toList()..sort((a, b) => b.compareTo(a)); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + // Calendar picker button + InkWell( + onTap: () => _pickDate(context, sessions), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7.5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: _selectedDate != null ? AppColors.primary : const Color(0xFFE2E8F0), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.calendar_month_rounded, + size: 16, + color: _selectedDate != null ? AppColors.primary : AppColors.textSecondary, + ), + const SizedBox(width: 4), + Icon( + Icons.arrow_drop_down_rounded, + size: 16, + color: _selectedDate != null ? AppColors.primary : AppColors.textSecondary, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + + // 'Semua Tanggal' Chip + ChoiceChip( + label: Text('Semua Tanggal (${sessions.length})'), + selected: _selectedDate == null, + onSelected: (_) => setState(() => _selectedDate = null), + selectedColor: AppColors.primary, + backgroundColor: Colors.white, + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: _selectedDate == null ? Colors.white : AppColors.textPrimary, + ), + side: BorderSide( + color: _selectedDate == null ? AppColors.primary : const Color(0xFFE2E8F0), + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + const SizedBox(width: 8), + + // Date Chips for unique session dates + ...sortedDates.map((date) { + final isSelected = _selectedDate != null && _isSameDay(_selectedDate!, date); + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(_formatShortDate(date)), + selected: isSelected, + onSelected: (_) => setState(() { + _selectedDate = isSelected ? null : date; + }), + selectedColor: AppColors.primary, + backgroundColor: Colors.white, + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + side: BorderSide( + color: isSelected ? AppColors.primary : const Color(0xFFE2E8F0), + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + }), + + // If a custom date was picked outside the existing list + if (_selectedDate != null && !sortedDates.any((d) => _isSameDay(d, _selectedDate!))) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + avatar: const Icon(Icons.check, size: 14, color: Colors.white), + label: Text(_formatShortDate(_selectedDate!)), + selected: true, + onSelected: (_) => setState(() => _selectedDate = null), + selectedColor: AppColors.primary, + backgroundColor: Colors.white, + labelStyle: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + side: const BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ), + ); + } + + /// Open Date Picker modal + Future _pickDate( + BuildContext context, + List sessions, + ) async { + final now = DateTime.now(); + final initial = _selectedDate ?? + (sessions.isNotEmpty ? sessions.first.sessionDateTime : now); + + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(2020), + lastDate: DateTime(2035), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: AppColors.textPrimary, + ), + ), + child: child ?? const SizedBox.shrink(), + ); + }, + ); + + if (picked != null) { + setState(() { + _selectedDate = picked; + }); + } + } + + /// Session Log Detail Card + Widget _buildSessionLogCard(ConsultationSessionEntity session) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row: Session # & Mode & Duration + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3.5), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Sesi #${session.sessionNumber}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + session.isOnlineVideo + ? Icons.videocam_rounded + : Icons.place_rounded, + size: 13, + color: AppColors.textSecondary, + ), + const SizedBox(width: 4), + Text( + session.isOnlineVideo ? 'Video Call' : 'Tatap Muka', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${session.durationMinutes} Menit', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + + const SizedBox(height: 10), + + // Date & Time + Row( + children: [ + const Icon( + Icons.calendar_today_rounded, + size: 14, + color: AppColors.primary, + ), + const SizedBox(width: 6), + Text( + '${_formatDate(session.sessionDateTime)} • ${_formatTime(session.sessionDateTime)} WIB', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + + const SizedBox(height: 12), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 12), + + // 1. Initial Complaint / Keluhan Pasien + if (session.notes != null && session.notes!.isNotEmpty) ...[ + Text( + 'TOPIK & KELUHAN AWAL', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFF1F5F9)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.format_quote_rounded, + size: 16, + color: Color(0xFF94A3B8), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + '"${session.notes}"', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontStyle: FontStyle.italic, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + ], + + // 2. Clinical Evaluation & Diagnosis Summary + if (session.diagnosisSummary != null && session.diagnosisSummary!.isNotEmpty) ...[ + Text( + 'RINGKASAN EVALUASI PSIKOLOG', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.primaryContainer), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.psychology_outlined, + size: 18, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + session.diagnosisSummary!, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + ], + + // 3. Saran & Rekomendasi Klinis (Aligned with Eva's Success Palette) + if (session.adviceList.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.successContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.success.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.lightbulb_rounded, + size: 17, + color: AppColors.successDark, + ), + const SizedBox(width: 6), + Text( + 'Saran & Rekomendasi Psikolog:', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w800, + color: AppColors.successDark, + ), + ), + ], + ), + const SizedBox(height: 10), + ...session.adviceList.asMap().entries.map((entry) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2), + width: 18, + height: 18, + decoration: BoxDecoration( + color: AppColors.successContainer, + shape: BoxShape.circle, + border: Border.all(color: AppColors.success), + ), + child: Center( + child: Text( + '${entry.key + 1}', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w800, + color: AppColors.successDark, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + entry.value, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + ), + ], + ), + ); + }), + ], + ), + ), + const SizedBox(height: 14), + ], + + // 4. Action Items / Latihan Mandiri (Homework) + if (session.actionItems.isNotEmpty) ...[ + Text( + 'LATIHAN MANDIRI (ACTION ITEMS)', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 6), + ...session.actionItems.map((item) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.check_circle_outline_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + height: 1.35, + ), + ), + ), + ], + ), + ); + }), + const SizedBox(height: 12), + ], + + // 5. Mood Progress Transition + if (session.moodBefore != null || session.moodAfter != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + const Icon( + Icons.trending_up_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Perubahan Kondisi Emosional: ${session.moodBefore ?? '-'} ➔ ${session.moodAfter ?? 'Membaik'}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + /// Empty state when no sessions match filter + Widget _buildEmptyState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + children: [ + const Icon( + Icons.event_busy_rounded, + size: 40, + color: Color(0xFF94A3B8), + ), + const SizedBox(height: 10), + Text( + _selectedDate != null + ? 'Tidak ada catatan sesi pada ${_formatShortDate(_selectedDate!)}' + : 'Tidak ada catatan sesi yang ditemukan', + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + if (_selectedDate != null) ...[ + const SizedBox(height: 10), + TextButton( + onPressed: () => setState(() => _selectedDate = null), + child: Text( + 'Tampilkan Semua Tanggal', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ], + ), + ); + } + + /// Persistent bottom dock for rebooking / scheduling new sessions + Widget _buildBottomActionDock() { + final counselorFirstName = widget.psychologist.name.split(',')[0]; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + border: const Border(top: BorderSide(color: Color(0xFFE2E8F0))), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 10, + offset: const Offset(0, -3), + ), + ], + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 13), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: () { + BookingConsultationSheet.show(context, widget.psychologist); + }, + icon: const Icon(Icons.event_available_rounded, size: 18), + label: Text( + 'Jadwalkan Sesi Baru Bersama $counselorFirstName', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_detail_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_detail_screen.dart new file mode 100644 index 0000000..39db460 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_detail_screen.dart @@ -0,0 +1,1175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../widgets/app_back_button.dart'; +import '../../../../widgets/glass_card.dart'; +import '../../domain/entities/psychologist_entity.dart'; +import '../widgets/booking_consultation_sheet.dart'; +import '../widgets/psychologist_certificate_carousel.dart'; + +/// Comprehensive profile detail screen for Psychologists and Volunteers. +class PsychologistDetailScreen extends ConsumerWidget { + final PsychologistEntity psychologist; + + const PsychologistDetailScreen({ + super.key, + required this.psychologist, + }); + + String _formatCoinFee(int coins) { + if (coins <= 0) return 'Gratis'; + return '$coins Koin'; + } + + Color _getTypeColor() { + switch (psychologist.practitionerType) { + case PractitionerType.professional: + return AppColors.primary; + case PractitionerType.freelance: + return const Color(0xFF4F46E5); + case PractitionerType.volunteer: + return const Color(0xFF0D9488); + } + } + + Color _getTypeBgColor() { + switch (psychologist.practitionerType) { + case PractitionerType.professional: + return AppColors.primaryContainer; + case PractitionerType.freelance: + return const Color(0xFFEEF2FF); + case PractitionerType.volunteer: + return const Color(0xFFCCFBF1); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final typeColor = _getTypeColor(); + final typeBgColor = _getTypeBgColor(); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + // 1. Navigation Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Profil Konselor', + style: GoogleFonts.plusJakartaSans( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + psychologist.typeLabel, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: typeColor, + ), + ), + ], + ), + ), + ], + ), + ), + + // 2. Scrollable Body + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Hero Profile Card + _buildHeroProfileCard(typeColor, typeBgColor), + + const SizedBox(height: 20), + + // Statistik Pasien yang Pernah Dirawat + _buildPatientStatsSection(), + + const SizedBox(height: 20), + + // About & Therapy Approach + _buildAboutSection(), + + const SizedBox(height: 20), + + // 5 Testimoni Pasien (Menggantikan Fokus Isu hingga Biaya Sesi) + _buildTestimonialsSection(context), + + const SizedBox(height: 32), + ], + ), + ), + ), + + // 3. Sticky Bottom Action Bar + _buildBottomBar(context), + ], + ), + ), + ); + } + + Widget _buildHeroProfileCard(Color typeColor, Color typeBgColor) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + CircleAvatar( + radius: 36, + backgroundImage: NetworkImage(psychologist.avatarUrl), + ), + Positioned( + bottom: 2, + right: 2, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: const Color(0xFF22C55E), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (psychologist.isVolunteer) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFF0F9FF), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFBAE6FD)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.volunteer_activism_rounded, + size: 11, + color: Color(0xFF0EA5E9), + ), + const SizedBox(width: 4), + Text( + 'Relawan', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF0369A1), + ), + ), + ], + ), + ), + if (psychologist.hasPsychologistCertificate) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.school_rounded, + size: 11, + color: Color(0xFF6366F1), + ), + const SizedBox(width: 4), + Text( + 'Profesional', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF3730A3), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.verified_rounded, size: 14, color: AppColors.primary), + const SizedBox(width: 4), + Expanded( + child: Text( + psychologist.registrationNumber, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.primary, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 14), + + // Stats Overview + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem( + icon: Icons.star_rounded, + iconColor: const Color(0xFFF59E0B), + title: psychologist.rating.toString(), + subtitle: '${psychologist.reviewCount} Ulasan', + ), + Container(width: 1, height: 28, color: const Color(0xFFE2E8F0)), + _buildStatItem( + icon: Icons.work_outline_rounded, + iconColor: AppColors.primary, + title: '${psychologist.experienceYears} Thn', + subtitle: 'Pengalaman', + ), + Container(width: 1, height: 28, color: const Color(0xFFE2E8F0)), + _buildStatItem( + icon: Icons.timer_outlined, + iconColor: const Color(0xFF0D9488), + title: '${psychologist.durationMinutes} Mnt', + subtitle: 'Per Sesi', + ), + ], + ), + ], + ), + ); + } + + Widget _buildStatItem({ + required IconData icon, + required Color iconColor, + required String title, + required String subtitle, + }) { + return Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: iconColor), + const SizedBox(width: 4), + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ); + } + + Widget _buildPatientStatsSection() { + final stats = psychologist.patientStats; + + return GlassCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.insights_rounded, + size: 18, + color: AppColors.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Statistik Pasien Dirawat', + style: GoogleFonts.plusJakartaSans( + fontSize: 14.5, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Berdasarkan riwayat konsultasi & pemulihan klinis', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + // 3 Metric Counters + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatMetricItem( + value: '${stats.totalPatientsTreated}+', + label: 'Pasien Dirawat', + icon: Icons.people_outline_rounded, + accentColor: AppColors.primary, + ), + Container(width: 1, height: 32, color: const Color(0xFFE2E8F0)), + _buildStatMetricItem( + value: '${stats.satisfactionRate.toStringAsFixed(1)}%', + label: 'Tingkat Puas', + icon: Icons.sentiment_very_satisfied_rounded, + accentColor: const Color(0xFF16A34A), + ), + Container(width: 1, height: 32, color: const Color(0xFFE2E8F0)), + _buildStatMetricItem( + value: '${stats.avgRecoverySessions.toStringAsFixed(1)} Sesi', + label: 'Rata-rata Selesai', + icon: Icons.check_circle_outline_rounded, + accentColor: const Color(0xFF0D9488), + ), + ], + ), + ), + + const SizedBox(height: 14), + + // Review Sentiment Ratio Bar (Positif vs Negatif) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFF16A34A), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '${stats.positiveReviewRate.toStringAsFixed(0)}% Ulasan Positif', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: const Color(0xFF15803D), + ), + ), + ], + ), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFDC2626), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '${stats.negativeReviewRate.toStringAsFixed(0)}% Catatan / Masukan', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: const Color(0xFFB91C1C), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + height: 8, + child: Row( + children: [ + Expanded( + flex: (stats.positiveReviewRate * 10).toInt(), + child: Container(color: const Color(0xFF22C55E)), + ), + Expanded( + flex: (stats.negativeReviewRate * 10).toInt(), + child: Container(color: const Color(0xFFEF4444)), + ), + ], + ), + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Clinical Recovery Description Banner + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF0FDF4), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFBBF7D0)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.verified_rounded, + size: 15, + color: Color(0xFF16A34A), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + stats.recoveryDescription, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: const Color(0xFF15803D), + height: 1.35, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatMetricItem({ + required String value, + required String label, + required IconData icon, + required Color accentColor, + }) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: accentColor), + const SizedBox(width: 4), + Text( + value, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + label, + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ); + } + + Widget _buildAboutSection() { + final title = psychologist.hasPsychologistCertificate + ? 'Tentang Psikolog & Kredensial Keahlian' + : 'Tentang Relawan & Kredensial Keahlian'; + + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + psychologist.hasPsychologistCertificate + ? Icons.psychology_outlined + : Icons.volunteer_activism_outlined, + size: 18, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 14.5, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + psychologist.bio, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 12), + + // Mitra Praktik / Afiliasi + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + Icon( + psychologist.clinicAffiliation != null + ? Icons.business_rounded + : Icons.group_work_outlined, + size: 15, + color: AppColors.primary, + ), + const SizedBox(width: 7), + Expanded( + child: Text( + psychologist.clinicAffiliation != null + ? 'Mitra Praktik: ${psychologist.clinicAffiliation}' + : 'Mitra Praktik: Relawan Komunitas Mandiri (Bebas Biaya)', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 14), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 12), + + // Sertifikat & Lisensi (Foto & Label Swipeable) + PsychologistCertificateCarousel( + certificateItems: psychologist.effectiveCertificateItems, + ), + ], + ), + ); + } + + /// 5 Patient Testimonials replacing the old Focus Issues & Session Cost sections + Widget _buildTestimonialsSection(BuildContext context) { + final allTestimonials = psychologist.effectiveTestimonials; + final displayedFive = allTestimonials.take(5).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + 'TESTIMONI PASIEN', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${allTestimonials.length}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + ], + ), + InkWell( + onTap: () => _showAllTestimonialsSheet(context), + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Lihat Semua', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.arrow_forward_ios_rounded, + size: 11, + color: AppColors.primary, + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + '5 ulasan pengalaman konseling pasien terverifikasi:', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + ...displayedFive.map((item) => _buildTestimonialCard(item)), + ], + ); + } + + Widget _buildTestimonialCard(PatientReviewItem item) { + final isPos = item.isPositive; + final sentimentBg = isPos ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2); + final sentimentBorder = isPos ? const Color(0xFFBBF7D0) : const Color(0xFFFECACA); + final sentimentText = isPos ? const Color(0xFF15803D) : const Color(0xFFB91C1C); + final sentimentIcon = isPos ? Icons.thumb_up_alt_rounded : Icons.thumb_down_alt_rounded; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row: Patient Profile, Rating Stars, Time, and Sentiment Badge + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: isPos ? const Color(0xFFDCFCE7) : const Color(0xFFFEE2E2), + child: Icon( + Icons.person_rounded, + size: 16, + color: sentimentText, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.patientName, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Row( + children: [ + Row( + children: List.generate(5, (starIndex) { + final fill = (starIndex + 1) <= item.rating.floor(); + return Icon( + fill ? Icons.star_rounded : Icons.star_half_rounded, + size: 12, + color: const Color(0xFFF59E0B), + ); + }), + ), + const SizedBox(width: 4), + Text( + item.rating.toStringAsFixed(1), + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: const Color(0xFFD97706), + ), + ), + const SizedBox(width: 6), + Text( + '• ${item.timeAgo}', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + ), + // Sentiment Icon (Thumbs up/down only, without Positif/Catatan text) + Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: sentimentBg, + shape: BoxShape.circle, + border: Border.all(color: sentimentBorder), + ), + child: Icon(sentimentIcon, size: 12, color: sentimentText), + ), + ], + ), + + const SizedBox(height: 10), + + // Ulasan Singkat (Tag Label) + if (item.label.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: sentimentBg, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.label_outline_rounded, + size: 12, + color: sentimentText, + ), + const SizedBox(width: 5), + Flexible( + child: Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: sentimentText, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 8), + + // Deskripsi Testimoni + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.format_quote_rounded, + size: 18, + color: Color(0xFF94A3B8), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + item.description, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontStyle: FontStyle.italic, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + ), + ], + ), + ], + ), + ); + } + + /// Interactive Modal Bottom Sheet allowing patients to see all testimonials with filters + void _showAllTestimonialsSheet(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (modalContext) { + String activeFilter = 'semua'; + + return StatefulBuilder( + builder: (context, setModalState) { + final allTestimonials = psychologist.effectiveTestimonials; + final positiveList = allTestimonials.where((t) => t.isPositive).toList(); + final negativeList = allTestimonials.where((t) => t.isNegative).toList(); + + List displayedList; + if (activeFilter == 'positif') { + displayedList = positiveList; + } else if (activeFilter == 'negatif') { + displayedList = negativeList; + } else { + displayedList = allTestimonials; + } + + return Container( + height: MediaQuery.of(context).size.height * 0.85, + decoration: const BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // Drag Handle + Container( + margin: const EdgeInsets.only(top: 10, bottom: 8), + width: 36, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFCBD5E1), + borderRadius: BorderRadius.circular(2), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Semua Testimoni Pasien', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + '${allTestimonials.length} Ulasan Terverifikasi • ${psychologist.name}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.pop(modalContext), + icon: const Icon(Icons.close_rounded, size: 20), + color: AppColors.textSecondary, + ), + ], + ), + ), + + // Filter Chips (Semua, Positif, Catatan) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + _buildFilterChip( + label: 'Semua (${allTestimonials.length})', + isActive: activeFilter == 'semua', + activeColor: AppColors.primary, + onTap: () => setModalState(() => activeFilter = 'semua'), + ), + const SizedBox(width: 8), + _buildFilterChip( + label: 'Positif (${positiveList.length})', + isActive: activeFilter == 'positif', + activeColor: const Color(0xFF16A34A), + onTap: () => setModalState(() => activeFilter = 'positif'), + ), + const SizedBox(width: 8), + _buildFilterChip( + label: 'Catatan (${negativeList.length})', + isActive: activeFilter == 'negatif', + activeColor: const Color(0xFFDC2626), + onTap: () => setModalState(() => activeFilter = 'negatif'), + ), + ], + ), + ), + + const Divider(height: 1, color: Color(0xFFE2E8F0)), + + // Testimonial List + Expanded( + child: displayedList.isEmpty + ? Center( + child: Text( + 'Tidak ada ulasan pada kategori ini.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + itemCount: displayedList.length, + itemBuilder: (context, index) { + return _buildTestimonialCard(displayedList[index]); + }, + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _buildFilterChip({ + required String label, + required bool isActive, + required Color activeColor, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isActive ? activeColor : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isActive ? activeColor : const Color(0xFFE2E8F0), + ), + ), + child: Text( + label, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: isActive ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ); + } + + Widget _buildBottomBar(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + border: const Border(top: BorderSide(color: Color(0xFFE2E8F0), width: 1)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, -3), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Biaya Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + if (psychologist.coinsPerSession > 0) ...[ + const Icon( + Icons.monetization_on_rounded, + size: 16, + color: Color(0xFFFFB300), + ), + const SizedBox(width: 4), + ], + Text( + _formatCoinFee(psychologist.coinsPerSession), + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w800, + color: psychologist.coinsPerSession > 0 + ? AppColors.textPrimary + : const Color(0xFF0D9488), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: () { + BookingConsultationSheet.show(context, psychologist); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), + child: Text( + 'Jadwalkan Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_video_call_screen.dart b/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_video_call_screen.dart new file mode 100644 index 0000000..906f702 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/screens/psychologist_video_call_screen.dart @@ -0,0 +1,841 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../providers/care_provider.dart'; +import '../widgets/consultation_result_bottom_sheet.dart'; + +/// Immersive Mock Video Call screen with licensed psychologists and peer counselors. +class PsychologistVideoCallScreen extends ConsumerStatefulWidget { + final ConsultationSessionEntity session; + + const PsychologistVideoCallScreen({ + super.key, + required this.session, + }); + + @override + ConsumerState createState() => + _PsychologistVideoCallScreenState(); +} + +class _PsychologistVideoCallScreenState + extends ConsumerState + with SingleTickerProviderStateMixin { + late Timer _timer; + int _secondsElapsed = 0; + bool _isMicMuted = false; + bool _isCameraOff = false; + bool _isFrontCamera = true; + bool _isSpeakerOn = true; + bool _isPsychologistSpeaking = true; + late AnimationController _pulseController; + + final List> _liveMessages = [ + { + 'sender': 'psychologist', + 'text': 'Halo, selamat datang di sesi konsultasi online kita hari ini.', + 'time': '00:05', + }, + { + 'sender': 'psychologist', + 'text': + 'Ambil posisi yang paling nyaman ya. Bagaimana perasaanmu hari ini?', + 'time': '00:15', + }, + { + 'sender': 'psychologist', + 'text': + 'Mari kita mulai dengan latihan pernapasan diafragma 4-7-8 sejenak untuk menenangkan detak jantung.', + 'time': '00:30', + }, + ]; + + final TextEditingController _chatController = TextEditingController(); + + @override + void initState() { + super.initState(); + _startTimer(); + + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + + // Alternate speaking indicator every few seconds + Timer.periodic(const Duration(seconds: 4), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + setState(() { + _isPsychologistSpeaking = !_isPsychologistSpeaking; + }); + }); + } + + void _startTimer() { + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + _secondsElapsed++; + }); + } + }); + } + + @override + void dispose() { + _timer.cancel(); + _pulseController.dispose(); + _chatController.dispose(); + super.dispose(); + } + + String _formatTimer(int seconds) { + final mins = (seconds ~/ 60).toString().padLeft(2, '0'); + final secs = (seconds % 60).toString().padLeft(2, '0'); + return '$mins:$secs'; + } + + void _handleEndCall() { + showDialog( + context: context, + builder: (dialogCtx) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.call_end_rounded, + color: Color(0xFFDC2626), + size: 20, + ), + ), + const SizedBox(width: 10), + Text( + 'Akhiri Sesi Konsultasi?', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + content: Text( + 'Sesi akan diselesaikan dan catatan rangkuman beserta saran psikolog akan otomatis tersimpan di Riwayat Bicara.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textSecondary, + height: 1.45, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogCtx), + child: Text( + 'Lanjutkan Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFDC2626), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () async { + Navigator.pop(dialogCtx); + await _completeAndExitCall(); + }, + child: Text( + 'Ya, Selesaikan', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ); + }, + ); + } + + Future _completeAndExitCall() async { + final advice = widget.session.adviceList.isNotEmpty + ? widget.session.adviceList + : const [ + 'Lakukan relaksasi napas 4-7-8 setiap pagi dan malam.', + 'Gunakan format restrukturisasi kognitif di Eva Diary saat overthinking.', + 'Jadwalkan sesi evaluasi lanjutan dalam 2 minggu ke depan.', + ]; + + final actionItems = widget.session.actionItems.isNotEmpty + ? widget.session.actionItems + : const [ + 'Tulis 3 hal yang disyukuri setiap malam di Jurnal Eva.', + 'Latih mindful pause 3 detik sebelum merespon saat merasa tegang.', + ]; + + const sessionSummary = + 'Sesi video call tatap muka online selesai dilaksanakan. Pasien menunjukkan perbaikan pemahaman emosi dan memiliki rencana aksi mandiri yang jelas.'; + const moodAfter = 'Lebih Tenang & Terarah (Skala 4/10)'; + + // 1. Mark session completed in Riverpod provider + await ref.read(consultationSessionsProvider.notifier).completeSession( + widget.session.id, + adviceList: advice, + actionItems: actionItems, + sessionSummary: sessionSummary, + moodAfter: moodAfter, + ); + + if (!mounted) return; + + final updatedSession = widget.session.copyWith( + status: ConsultationStatus.completed, + adviceList: advice, + actionItems: actionItems, + sessionSummary: sessionSummary, + moodAfter: moodAfter, + ); + + // 2. Langsung tampilkan bottom sheet saran sesi hasil konsultasi (bukan dilarikan ke log history) + await ConsultationResultBottomSheet.show( + context, + updatedSession, + onClose: () { + if (mounted) { + Navigator.pop(context); + } + }, + ); + + if (mounted) { + Navigator.pop(context); + } + } + + void _openLiveChatNotesSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + height: MediaQuery.of(context).size.height * 0.7, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // Drag Handle + Container( + margin: const EdgeInsets.only(top: 10, bottom: 8), + width: 36, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFCBD5E1), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon( + Icons.chat_bubble_outline_rounded, + size: 18, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Text( + 'Catatan & Chat Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + IconButton( + icon: const Icon(Icons.close_rounded, size: 20), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ), + const Divider(height: 1, color: Color(0xFFE2E8F0)), + + // Messages List + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _liveMessages.length, + itemBuilder: (context, index) { + final msg = _liveMessages[index]; + final isPsy = msg['sender'] == 'psychologist'; + + return Align( + alignment: isPsy ? Alignment.centerLeft : Alignment.centerRight, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.78, + ), + decoration: BoxDecoration( + color: isPsy + ? const Color(0xFFF1F5F9) + : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: isPsy + ? CrossAxisAlignment.start + : CrossAxisAlignment.end, + children: [ + Text( + isPsy + ? widget.session.psychologist.name + : 'Kamu (Pasien)', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: isPsy + ? AppColors.textSecondary + : AppColors.primary, + ), + ), + const SizedBox(height: 4), + Text( + msg['text'] ?? '', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textPrimary, + height: 1.35, + ), + ), + const SizedBox(height: 2), + Text( + msg['time'] ?? '', + style: GoogleFonts.plusJakartaSans( + fontSize: 9.5, + color: AppColors.textLight, + ), + ), + ], + ), + ), + ); + }, + ), + ), + + // Chat Input Box + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _chatController, + style: GoogleFonts.plusJakartaSans(fontSize: 13), + decoration: InputDecoration( + hintText: 'Kirim pesan atau catatan...', + hintStyle: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textLight, + ), + filled: true, + fillColor: const Color(0xFFF8FAFC), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send_rounded, color: AppColors.primary), + onPressed: () { + final text = _chatController.text.trim(); + if (text.isNotEmpty) { + setState(() { + _liveMessages.add({ + 'sender': 'user', + 'text': text, + 'time': _formatTimer(_secondsElapsed), + }); + }); + _chatController.clear(); + Navigator.pop(ctx); + } + }, + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final psychologist = widget.session.psychologist; + + return Scaffold( + backgroundColor: const Color(0xFF0F172A), + body: SafeArea( + child: Stack( + children: [ + // 1. Fullscreen Main Video Feed (Psychologist) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFF0F172A), + const Color(0xFF1E293B), + const Color(0xFF0F172A), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + // Psychologist Video Feed Mock Image + Opacity( + opacity: 0.88, + child: Image.network( + psychologist.avatarUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: const Color(0xFF1E293B), + child: const Icon( + Icons.person, + size: 100, + color: Colors.white30, + ), + ), + ), + ), + + // Cinematic Gradient Overlays + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.black.withValues(alpha: 0.65), + Colors.transparent, + Colors.black.withValues(alpha: 0.85), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.45, 1.0], + ), + ), + ), + ), + + // Speaking Indicator Waves + if (_isPsychologistSpeaking) + Positioned( + bottom: 120, + left: 20, + right: 20, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: const Color(0xFF22C55E).withValues(alpha: 0.6), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.graphic_eq_rounded, + color: Color(0xFF22C55E), + size: 16, + ), + const SizedBox(width: 8), + Text( + '${psychologist.name} sedang berbicara...', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + + // 2. Top Bar (Header & Timer) + Positioned( + top: 14, + left: 16, + right: 16, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Psychologist Header Info + Expanded( + child: Row( + children: [ + CircleAvatar( + radius: 18, + backgroundImage: NetworkImage(psychologist.avatarUrl), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Row( + children: [ + Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + color: Color(0xFF22C55E), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + 'Online • Sesi #${widget.session.sessionNumber}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white70, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + + // Timer Badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.fiber_manual_record_rounded, + color: Color(0xFFEF4444), + size: 11, + ), + const SizedBox(width: 6), + Text( + _formatTimer(_secondsElapsed), + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ], + ), + ), + + // 3. User Floating PIP Camera Preview (Self-View) + Positioned( + top: 76, + right: 16, + child: GestureDetector( + onTap: () { + setState(() => _isFrontCamera = !_isFrontCamera); + }, + child: Container( + width: 104, + height: 148, + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withValues(alpha: 0.4), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + fit: StackFit.expand, + children: [ + if (_isCameraOff) + Container( + color: const Color(0xFF0F172A), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.videocam_off_rounded, + color: Colors.white54, + size: 26, + ), + const SizedBox(height: 4), + Text( + 'Kamera Mati', + style: GoogleFonts.plusJakartaSans( + fontSize: 9, + color: Colors.white54, + ), + ), + ], + ), + ), + ) + else + Container( + color: const Color(0xFF334155), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 24, + backgroundColor: AppColors.primary, + child: const Icon( + Icons.person, + size: 28, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + Text( + 'Kamu', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ), + // Camera Flip hint + Positioned( + bottom: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.flip_camera_ios_rounded, + size: 12, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + ), + + // 4. Bottom Video Call Action Control Bar + Positioned( + bottom: 24, + left: 20, + right: 20, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFF1E293B).withValues(alpha: 0.94), + borderRadius: BorderRadius.circular(28), + border: Border.all(color: Colors.white12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + // Mute / Unmute Mic + _buildControlButton( + icon: _isMicMuted + ? Icons.mic_off_rounded + : Icons.mic_rounded, + isActive: !_isMicMuted, + activeColor: Colors.white24, + iconColor: _isMicMuted + ? const Color(0xFFEF4444) + : Colors.white, + onTap: () => setState(() => _isMicMuted = !_isMicMuted), + ), + + // Camera On / Off + _buildControlButton( + icon: _isCameraOff + ? Icons.videocam_off_rounded + : Icons.videocam_rounded, + isActive: !_isCameraOff, + activeColor: Colors.white24, + iconColor: _isCameraOff + ? const Color(0xFFEF4444) + : Colors.white, + onTap: () => setState(() => _isCameraOff = !_isCameraOff), + ), + + // Flip Camera + _buildControlButton( + icon: Icons.flip_camera_ios_rounded, + isActive: true, + activeColor: Colors.white24, + iconColor: Colors.white, + onTap: () => setState(() => _isFrontCamera = !_isFrontCamera), + ), + + // Chat & Notes Drawer + _buildControlButton( + icon: Icons.chat_bubble_outline_rounded, + isActive: true, + activeColor: AppColors.primary, + iconColor: Colors.white, + onTap: _openLiveChatNotesSheet, + ), + + // End Call Button (Red) + GestureDetector( + onTap: _handleEndCall, + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Color(0xFFDC2626), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.call_end_rounded, + color: Colors.white, + size: 26, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildControlButton({ + required IconData icon, + required bool isActive, + required Color activeColor, + required Color iconColor, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: activeColor, + shape: BoxShape.circle, + ), + child: Icon(icon, color: iconColor, size: 22), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/booking_consultation_sheet.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/booking_consultation_sheet.dart index 8d83fae..c641d20 100644 --- a/apps/eva_mobile/lib/features/care/presentation/widgets/booking_consultation_sheet.dart +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/booking_consultation_sheet.dart @@ -5,6 +5,8 @@ import '../../../../theme/app_colors.dart'; import '../../domain/entities/psychologist_entity.dart'; import '../../domain/entities/consultation_session_entity.dart'; import '../providers/care_provider.dart'; +import '../../../coin/presentation/providers/coin_provider.dart'; +import 'clinic_location_map_card.dart'; class BookingConsultationSheet extends ConsumerStatefulWidget { final PsychologistEntity psychologist; @@ -48,6 +50,30 @@ class _BookingConsultationSheetState extends ConsumerState _handleConfirmBooking() async { + final requiredCoins = widget.psychologist.coinsPerSession; + final currentCoins = ref.read(coinBalanceProvider); + + if (requiredCoins > 0 && currentCoins < requiredCoins) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Saldo koin tidak mencukupi untuk konsultasi ini ($requiredCoins Koin).', + style: GoogleFonts.plusJakartaSans(fontSize: 12), + ), + backgroundColor: AppColors.error, + action: SnackBarAction( + label: 'Top Up', + textColor: Colors.white, + onPressed: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/coin_purchase'); + }, + ), + ), + ); + return; + } + setState(() => _isLoading = true); final targetDate = DateTime.now().add(Duration(days: _selectedDayOffset)); @@ -74,6 +100,11 @@ class _BookingConsultationSheetState extends ConsumerState _isLoading = false); if (success) { + // Deduct coins if required + if (requiredCoins > 0) { + await ref.read(coinBalanceProvider.notifier).spendCoins(requiredCoins); + } + if (!mounted) return; Navigator.pop(context); _showSuccessDialog(); } else { @@ -87,6 +118,11 @@ class _BookingConsultationSheetState extends ConsumerState 0 + ? 'Sesi konsultasi Anda bersama ${widget.psychologist.name} telah berhasil dijadwalkan dengan transaksi $coins Koin EVA.' + : 'Sesi konsultasi Anda bersama ${widget.psychologist.name} telah berhasil dijadwalkan secara gratis.'; + showDialog( context: context, builder: (ctx) => AlertDialog( @@ -99,10 +135,10 @@ class _BookingConsultationSheetState extends ConsumerState setState(() => _selectedTimeSlot = index), selectedColor: AppColors.primary, + checkmarkColor: Colors.white, backgroundColor: const Color(0xFFF1F5F9), labelStyle: GoogleFonts.plusJakartaSans( fontSize: 12, @@ -339,7 +386,82 @@ class _BookingConsultationSheetState extends ConsumerState 0 + ? 'Biaya: ${widget.psychologist.coinsPerSession} Koin • Saldo koinmu: ${ref.watch(coinBalanceProvider)} Koin' + : 'Bebas biaya koin (Gratis) • Saldo koinmu: ${ref.watch(coinBalanceProvider)} Koin', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + if (widget.psychologist.coinsPerSession > 0 && + ref.watch(coinBalanceProvider) < widget.psychologist.coinsPerSession) + InkWell( + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/coin_purchase'); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Top Up', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 18), // CTA Button SizedBox( @@ -442,6 +564,7 @@ class _BookingConsultationSheetState extends ConsumerState setState(() => _selectedDayOffset = offset), selectedColor: AppColors.primary, + checkmarkColor: Colors.white, backgroundColor: const Color(0xFFF1F5F9), labelStyle: GoogleFonts.plusJakartaSans( fontSize: 12, diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/care_quick_actions.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/care_quick_actions.dart new file mode 100644 index 0000000..8104735 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/care_quick_actions.dart @@ -0,0 +1,692 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../coin/presentation/providers/coin_provider.dart'; +import '../../domain/entities/consultation_session_entity.dart'; +import '../providers/care_provider.dart'; +import 'booking_consultation_sheet.dart'; + +/// 4 Quick Action Buttons for Care / Layanan Screen: +/// 1. Jadwal Sesi (Sesi yang telah dibooking) +/// 2. Riwayat Bicara (History pernah bicara dengan siapa saja) +/// 3. Koin Saya (Saldo koin & top up cepat) +/// 4. Panduan & FAQ (Panduan konseling, perbedaan psikolog & etika) +class CareQuickActions extends ConsumerWidget { + const CareQuickActions({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final coinBalance = ref.watch(coinBalanceProvider); + final sessionsAsync = ref.watch(consultationSessionsProvider); + + final upcomingCount = sessionsAsync.value + ?.where((s) => s.status == ConsultationStatus.upcoming) + .length ?? + 0; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _buildActionCard( + context: context, + title: 'Jadwal', + subtitle: upcomingCount > 0 ? '$upcomingCount Sesi Aktif' : 'Cek Jadwal', + badge: upcomingCount > 0 ? '$upcomingCount' : null, + icon: Icons.calendar_today_rounded, + iconColor: AppColors.primary, + iconBgColor: AppColors.primaryContainer, + onTap: () => Navigator.pushNamed(context, '/care/schedule'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildActionCard( + context: context, + title: 'Riwayat', + subtitle: 'History Sesi', + icon: Icons.history_rounded, + iconColor: const Color(0xFF6366F1), + iconBgColor: const Color(0xFFEEF2FF), + onTap: () => Navigator.pushNamed(context, '/care/history'), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _buildActionCard( + context: context, + title: 'Koin Saya', + subtitle: '$coinBalance Koin', + icon: Icons.monetization_on_rounded, + iconColor: const Color(0xFFD97706), + iconBgColor: const Color(0xFFFEF3C7), + onTap: () { + Navigator.pushNamed(context, '/coin_purchase'); + }, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildActionCard( + context: context, + title: 'Panduan', + subtitle: 'Etika & FAQ', + icon: Icons.help_outline_rounded, + iconColor: const Color(0xFF0D9488), + iconBgColor: const Color(0xFFCCFBF1), + onTap: () => _showCareGuideModal(context), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildActionCard({ + required BuildContext context, + required String title, + required String subtitle, + required IconData icon, + required Color iconColor, + required Color iconBgColor, + required VoidCallback onTap, + String? badge, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: iconBgColor, + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Icon(icon, color: iconColor, size: 20), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (badge != null) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + badge, + style: GoogleFonts.plusJakartaSans( + fontSize: 9, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + size: 16, + color: Color(0xFFCBD5E1), + ), + ], + ), + ), + ); + } + + void _showUpcomingSessionsModal(BuildContext context, WidgetRef ref) { + final sessions = ref.read(consultationSessionsProvider).value ?? []; + final upcomingSessions = + sessions.where((s) => s.status == ConsultationStatus.upcoming).toList(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 28), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 18), + Row( + children: [ + const Icon(Icons.event_available_rounded, color: AppColors.primary, size: 22), + const SizedBox(width: 8), + Text( + 'Jadwal Sesi Mendatang', + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Daftar janji konsultasi yang telah berhasil kamu booking.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 16), + if (upcomingSessions.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + const Icon(Icons.calendar_month_outlined, size: 36, color: Color(0xFF94A3B8)), + const SizedBox(height: 8), + Text( + 'Belum Ada Jadwal Aktif', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Pilih psikolog atau volunteer di bawah untuk membuat janji baru.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ) + else + ...upcomingSessions.map((session) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF3F4FE), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.primaryContainer), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 20, + backgroundImage: NetworkImage(session.psychologist.avatarUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + session.psychologist.typeLabel, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.successContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Terkonfirmasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.successDark, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + const Icon(Icons.access_time_rounded, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Text( + '${session.sessionDateTime.day}/${session.sessionDateTime.month}/${session.sessionDateTime.year} • ${_formatTime(session.sessionDateTime)} WIB', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + 'Keluhan: "${session.notes}"', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontStyle: FontStyle.italic, + color: AppColors.textSecondary, + ), + ), + ], + ], + ), + ); + }), + ], + ), + ); + }, + ); + } + + void _showConsultationHistoryModal(BuildContext context, WidgetRef ref) { + final sessions = ref.read(consultationSessionsProvider).value ?? []; + final completedSessions = + sessions.where((s) => s.status == ConsultationStatus.completed).toList(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 28), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 18), + Row( + children: [ + const Icon(Icons.history_rounded, color: Color(0xFF6366F1), size: 22), + const SizedBox(width: 8), + Text( + 'Riwayat Bicara & Konseling', + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Daftar psikolog dan volunteer yang pernah kamu ajak bicara.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 16), + if (completedSessions.isEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + const Icon(Icons.forum_outlined, size: 36, color: Color(0xFF94A3B8)), + const SizedBox(height: 8), + Text( + 'Belum Ada Riwayat Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Setelah sesi selesai, catatan refleksi dan diagnosis akan tersimpan di sini.', + textAlign: TextAlign.center, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + ), + ], + ), + ) + else + ...completedSessions.map((session) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 20, + backgroundImage: NetworkImage(session.psychologist.avatarUrl), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + session.psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + InkWell( + onTap: () { + Navigator.pop(ctx); + BookingConsultationSheet.show(context, session.psychologist); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Bicara Lagi', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ), + ], + ), + if (session.notes != null && session.notes!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'Catatan: ${session.notes}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ), + ], + ], + ), + ); + }), + ], + ), + ); + }, + ); + } + + void _showCareGuideModal(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 28), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 18), + Row( + children: [ + const Icon(Icons.menu_book_rounded, color: Color(0xFF0D9488), size: 22), + const SizedBox(width: 8), + Text( + 'Panduan Layanan Konseling', + style: GoogleFonts.plusJakartaSans( + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 14), + _buildGuideSection( + icon: Icons.verified_user_rounded, + title: 'Psikolog Profesional Independen', + desc: + 'Memiliki izin praktik resmi SIPP/STR. Cocok untuk asesmen klinis mendalam, depresi, kecemasan akut, trauma, dan psikoterapi terstruktur.', + ), + const SizedBox(height: 10), + _buildGuideSection( + icon: Icons.work_outline_rounded, + title: 'Psikolog Freelance', + desc: + 'Praktisi psikolog berlisensi yang fleksibel menangani konseling karir, quarter-life crisis, relasi hubungan, dan self-growth.', + ), + const SizedBox(height: 10), + _buildGuideSection( + icon: Icons.favorite_border_rounded, + title: 'Volunteer Konselor Sebaya', + desc: + 'Relawan terlatih Psychological First Aid (PFA). Menyediakan ruang dengar hangat dan curhat tanpa biaya untuk teman sebaya.', + ), + const SizedBox(height: 10), + _buildGuideSection( + icon: Icons.monetization_on_rounded, + title: 'Transaksi Koin EVA', + desc: + 'Seluruh sesi dapat dibayar menggunakan Koin EVA (atau transfer). Sesi volunteer gratis atau donasi koin sukarela.', + ), + ], + ), + ); + }, + ); + } + + Widget _buildGuideSection({ + required IconData icon, + required String title, + required String desc, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFFF0FDFA), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 16, color: const Color(0xFF0D9488)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + desc, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } + + String _formatTime(DateTime dt) { + final h = dt.hour.toString().padLeft(2, '0'); + final m = dt.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_location_map_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_location_map_card.dart new file mode 100644 index 0000000..c9fcaa3 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/clinic_location_map_card.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../theme/app_colors.dart'; + +/// Clean clinic location card with a direct button to open Google Maps. +/// Free from simulated canvas maps or mock drawing widgets. +class ClinicLocationMapCard extends StatelessWidget { + final String clinicName; + final String? address; + final double distanceKm; + final VoidCallback? onOpenMaps; + + const ClinicLocationMapCard({ + super.key, + required this.clinicName, + this.address, + this.distanceKm = 2.4, + this.onOpenMaps, + }); + + String get _effectiveAddress => + address ?? 'Jl. Kemanggisan Raya No. 45, Palmerah, Jakarta Barat'; + + /// Launches Google Maps with randomized realistic clinic location coordinates. + static Future launchRandomGoogleMaps({ + BuildContext? context, + String? query, + }) async { + final randomTargets = [ + '-6.1952,106.7821', // Jakarta Barat + '-6.1754,106.8272', // Gambir, Jakarta Pusat + '-6.2297,106.8166', // Sudirman, Jakarta Selatan + '-6.1805,106.8284', // Menteng, Jakarta Pusat + '-6.2615,106.8106', // Kemang, Jakarta Selatan + ]; + final seed = DateTime.now().millisecondsSinceEpoch % randomTargets.length; + final selectedCoords = randomTargets[seed]; + final encodedQuery = query != null + ? Uri.encodeComponent('$query, Jakarta') + : selectedCoords; + + final url = Uri.parse('https://www.google.com/maps/search/?api=1&query=$encodedQuery'); + + try { + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } else if (context != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Membuka Google Maps ($encodedQuery)...'), + duration: const Duration(seconds: 2), + ), + ); + } + } catch (_) { + if (context != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Membuka rute di Google Maps ($encodedQuery)...'), + duration: const Duration(seconds: 2), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon( + Icons.storefront_rounded, + size: 16, + color: Color(0xFF0D9488), + ), + const SizedBox(width: 6), + Text( + 'Lokasi Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F766E), + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFCCFBF1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${distanceKm.toStringAsFixed(1)} km', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F766E), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + + // Clinic Name & Address + Text( + clinicName, + style: GoogleFonts.plusJakartaSans( + fontSize: 13.5, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + _effectiveAddress, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + height: 1.35, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_history_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_history_card.dart index 7d0ad6c..54eb8ce 100644 --- a/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_history_card.dart +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_history_card.dart @@ -49,7 +49,7 @@ class ConsultationHistoryCard extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: isUpcoming ? const Color(0xFFDCFCE7) : const Color(0xFFF1F5F9), + color: isUpcoming ? AppColors.successContainer : const Color(0xFFF1F5F9), borderRadius: BorderRadius.circular(6), ), child: Row( @@ -58,7 +58,7 @@ class ConsultationHistoryCard extends StatelessWidget { Icon( isUpcoming ? Icons.event_available : Icons.history, size: 13, - color: isUpcoming ? const Color(0xFF166534) : const Color(0xFF475569), + color: isUpcoming ? AppColors.successDark : const Color(0xFF475569), ), const SizedBox(width: 4), Text( @@ -66,7 +66,7 @@ class ConsultationHistoryCard extends StatelessWidget { style: GoogleFonts.plusJakartaSans( fontSize: 10.5, fontWeight: FontWeight.w800, - color: isUpcoming ? const Color(0xFF166534) : const Color(0xFF475569), + color: isUpcoming ? AppColors.successDark : const Color(0xFF475569), ), ), ], @@ -140,7 +140,7 @@ class ConsultationHistoryCard extends StatelessWidget { color: isUpcoming ? Colors.white : const Color(0xFFF8FAFC), borderRadius: BorderRadius.circular(10), border: Border.all( - color: isUpcoming ? const Color(0xFFDCFCE7) : const Color(0xFFE2E8F0), + color: isUpcoming ? AppColors.successContainer : const Color(0xFFE2E8F0), ), ), child: Row( @@ -172,7 +172,7 @@ class ConsultationHistoryCard extends StatelessWidget { child: ElevatedButton.icon( onPressed: onActionTap, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF16A34A), + backgroundColor: AppColors.success, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), padding: const EdgeInsets.symmetric(vertical: 8), diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_result_bottom_sheet.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_result_bottom_sheet.dart new file mode 100644 index 0000000..6eda94f --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/consultation_result_bottom_sheet.dart @@ -0,0 +1,471 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/consultation_session_entity.dart'; + +/// Modal bottom sheet displaying detailed consultation results, clinical advice, +/// mood progression, and homework action items when a session finishes. +class ConsultationResultBottomSheet extends StatelessWidget { + final ConsultationSessionEntity session; + final VoidCallback? onClose; + + const ConsultationResultBottomSheet({ + super.key, + required this.session, + this.onClose, + }); + + static Future show( + BuildContext context, + ConsultationSessionEntity session, { + VoidCallback? onClose, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => ConsultationResultBottomSheet( + session: session, + onClose: onClose, + ), + ); + } + + @override + Widget build(BuildContext context) { + final advice = session.adviceList.isNotEmpty + ? session.adviceList + : const [ + 'Lakukan relaksasi napas diafragma 4-7-8 setiap pagi dan 15 menit sebelum tidur.', + 'Terapkan teknik restrukturisasi kognitif di Eva Diary saat mulai overthinking.', + 'Luangkan jeda hening (mindful pause) 3-5 menit di sela aktivitas kerja.', + ]; + + final actionItems = session.actionItems.isNotEmpty + ? session.actionItems + : const [ + 'Tuliskan 3 bukti objektif pencapaian dan hal yang disyukuri setiap malam.', + 'Latih penetapan batasan tegas (boundary setting) dalam komunikasi profesional.', + ]; + + final summary = session.sessionSummary ?? + 'Sesi konsultasi tatap muka selesai dilaksanakan dengan baik. Pasien menunjukkan keterbukaan dalam mengidentifikasi pemicu stres dan memiliki rencana aksi mandiri yang jelas.'; + + return Container( + height: MediaQuery.of(context).size.height * 0.88, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // Drag Handle + const SizedBox(height: 12), + Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFCBD5E1), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: const BoxDecoration( + color: Color(0xFFDCFCE7), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_circle_rounded, + color: Color(0xFF16A34A), + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Hasil & Saran Sesi Konsultasi', + style: GoogleFonts.plusJakartaSans( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + '${session.psychologist.name} • ${session.psychologist.title}', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, color: AppColors.textLight), + onPressed: () { + Navigator.pop(context); + if (onClose != null) onClose!(); + }, + ), + ], + ), + ), + + const SizedBox(height: 10), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + + // Scrollable Content + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Session Metric Tag + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Sesi #${session.sessionNumber} Selesai', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Durasi: ${session.durationMinutes} Menit', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // 1. Rangkuman Hasil Konsultasi (Summary) + Text( + 'RANGKUMAN HASIL KONSULTASI', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.format_quote_rounded, + size: 20, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + summary, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + color: AppColors.textPrimary, + height: 1.45, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 18), + + // 2. Perubahan Suasana Hati / Mood Transition + Text( + 'EVALUASI KONDISI EMOSI PASIEN', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sebelum Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + session.moodBefore ?? 'Cemas Tinggi (8/10)', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFFEF4444), + ), + ), + ], + ), + ), + const Icon( + Icons.arrow_forward_rounded, + size: 18, + color: Color(0xFF94A3B8), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Sesudah Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + session.moodAfter ?? 'Lebih Tenang (4/10)', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF16A34A), + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 18), + + // 3. Saran & Rekomendasi Klinis (Advice List) + Row( + children: [ + const Icon( + Icons.lightbulb_outline_rounded, + size: 16, + color: Color(0xFFF59E0B), + ), + const SizedBox(width: 6), + Text( + 'SARAN & REKOMENDASI PSIKOLOG', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 8), + Column( + children: List.generate(advice.length, (idx) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFDE68A)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 20, + height: 20, + decoration: const BoxDecoration( + color: Color(0xFFF59E0B), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${idx + 1}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + advice[idx], + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: const Color(0xFF78350F), + height: 1.35, + ), + ), + ), + ], + ), + ); + }), + ), + + const SizedBox(height: 14), + + // 4. Latihan Mandiri (Action Items) + Row( + children: [ + const Icon( + Icons.check_circle_outline_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 6), + Text( + 'LATIHAN MANDIRI (ACTION ITEMS)', + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + fontWeight: FontWeight.w800, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + ], + ), + const SizedBox(height: 8), + Column( + children: actionItems.map((item) { + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: Row( + children: [ + const Icon( + Icons.task_alt_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w600, + color: const Color(0xFF3730A3), + ), + ), + ), + ], + ), + ); + }).toList(), + ), + + const SizedBox(height: 24), + ], + ), + ), + ), + + // Bottom Action Button + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Color(0xFFF1F5F9))), + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: () { + Navigator.pop(context); + if (onClose != null) onClose!(); + }, + child: Text( + 'Selesai & Tutup Saran Sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/dense_review_tags_wrap.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/dense_review_tags_wrap.dart new file mode 100644 index 0000000..90fbd89 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/dense_review_tags_wrap.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../domain/entities/psychologist_entity.dart'; + +/// Reusable dense bin-packing review tags widget. +/// Guarantees at most [maxRows] lines. Intelligently selects combinations of tags +/// that fit within the available width. If a tag does not fit comfortably in the +/// remaining space, it is simply omitted ("tidak usah ditampilkan") to ensure zero overflow. +class DenseReviewTagsWrap extends StatelessWidget { + final List tags; + final int maxRows; + final double fontSize; + final double iconSize; + final EdgeInsets padding; + final double spacing; + final double runSpacing; + + const DenseReviewTagsWrap({ + super.key, + required this.tags, + this.maxRows = 2, + this.fontSize = 10.5, + this.iconSize = 11, + this.padding = const EdgeInsets.symmetric(horizontal: 7.5, vertical: 3.5), + this.spacing = 6, + this.runSpacing = 6, + }); + + @override + Widget build(BuildContext context) { + if (tags.isEmpty) return const SizedBox.shrink(); + + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + if (maxWidth <= 0 || maxWidth.isInfinite) { + return const SizedBox.shrink(); + } + + final textScaler = MediaQuery.textScalerOf(context); + final textStyle = GoogleFonts.plusJakartaSans( + fontSize: fontSize, + fontWeight: FontWeight.w600, + ); + + // Measure each tag width with actual device TextScaler + safety cushion + final tagWidths = {}; + for (final tag in tags) { + final tp = TextPainter( + text: TextSpan(text: tag.label, style: textStyle), + textDirection: TextDirection.ltr, + textScaler: textScaler, + maxLines: 1, + )..layout(); + // Total conservative width: text + icon + gap + horizontal padding + border + safe buffer + tagWidths[tag] = tp.width + iconSize + 5.0 + padding.horizontal + 2.0 + 6.0; + } + + // Greedy knapsack bin-packing: pack candidates into up to maxRows + // Strictly omit any tag that cannot fit completely within the line budget + final remaining = List.of(tags); + final rows = >[]; + + for (int r = 0; r < maxRows; r++) { + if (remaining.isEmpty) break; + + final currentRow = []; + double currentWidth = 0.0; + + while (remaining.isNotEmpty) { + final spaceLeft = currentRow.isEmpty + ? maxWidth + : (maxWidth - currentWidth - spacing); + + if (spaceLeft <= 0) break; + + // Find best candidate that fits within spaceLeft + PatientReviewTag? bestCandidate; + double bestCandidateWidth = 0.0; + + for (final candidate in remaining) { + final w = tagWidths[candidate] ?? 70.0; + if (w <= spaceLeft) { + // Pick candidate that fills the line most densely + if (bestCandidate == null || w > bestCandidateWidth) { + bestCandidate = candidate; + bestCandidateWidth = w; + } + } + } + + if (bestCandidate != null) { + currentRow.add(bestCandidate); + currentWidth += (currentRow.length == 1 + ? bestCandidateWidth + : bestCandidateWidth + spacing); + remaining.remove(bestCandidate); + } else { + // If no candidate fits into the remaining space, stop this row! + // Do NOT force any candidate into the row. + break; + } + } + + if (currentRow.isNotEmpty) { + rows.add(currentRow); + } else { + // Even on an empty row, no remaining tag fits in maxWidth. + // Do not display any further tags. + break; + } + } + + if (rows.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: rows.asMap().entries.map((entry) { + final rowIndex = entry.key; + final rowTags = entry.value; + final isLastRow = rowIndex == rows.length - 1; + + return Padding( + padding: EdgeInsets.only(bottom: isLastRow ? 0 : runSpacing), + child: Wrap( + spacing: spacing, + runSpacing: runSpacing, + children: rowTags.map((tag) { + final isPos = tag.isPositive; + final bgColor = isPos ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2); + final borderColor = isPos ? const Color(0xFFBBF7D0) : const Color(0xFFFECACA); + final textColor = isPos ? const Color(0xFF15803D) : const Color(0xFFB91C1C); + final iconColor = isPos ? const Color(0xFF16A34A) : const Color(0xFFDC2626); + final icon = isPos ? Icons.thumb_up_alt_rounded : Icons.thumb_down_alt_rounded; + + return Container( + padding: padding, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: iconSize, color: iconColor), + const SizedBox(width: 4.5), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth > 40 ? maxWidth - iconSize - 20 : 120), + child: Text( + tag.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: fontSize, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ); + }).toList(), + ); + }, + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_card.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_card.dart index 7db00a8..cf6bc19 100644 --- a/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_card.dart +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_card.dart @@ -2,236 +2,302 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../../theme/app_colors.dart'; import '../../domain/entities/psychologist_entity.dart'; +import 'dense_review_tags_wrap.dart'; class PsychologistCard extends StatelessWidget { final PsychologistEntity psychologist; final VoidCallback onBookTap; + final VoidCallback? onTap; const PsychologistCard({ super.key, required this.psychologist, required this.onBookTap, + this.onTap, }); - String _formatCurrency(int amount) { - final str = amount.toString(); - final buffer = StringBuffer(); - int count = 0; - for (int i = str.length - 1; i >= 0; i--) { - buffer.write(str[i]); - count++; - if (count % 3 == 0 && i != 0) { - buffer.write('.'); - } - } - return 'Rp ${buffer.toString().split('').reversed.join('')}'; - } - @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(18), - border: Border.all(color: const Color(0xFFEAEBFA)), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.05), - blurRadius: 14, - offset: const Offset(0, 4), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFEAEBFA)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.05), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], ), - ], - ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + padding: const EdgeInsets.all(16), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Avatar with Online indicator - Stack( + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 28, - backgroundColor: AppColors.primaryContainer, - backgroundImage: NetworkImage(psychologist.avatarUrl), - onBackgroundImageError: (exception, stackTrace) {}, - child: Text( - psychologist.name.substring(0, 2).toUpperCase(), - style: GoogleFonts.plusJakartaSans( - fontWeight: FontWeight.w700, - color: AppColors.primary, - ), - ), - ), - if (psychologist.availableOnline) - Positioned( - bottom: 0, - right: 0, - child: Container( - width: 13, - height: 13, - decoration: BoxDecoration( - color: const Color(0xFF22C55E), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), + // Avatar with Online indicator + Stack( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: AppColors.primaryContainer, + backgroundImage: NetworkImage(psychologist.avatarUrl), + onBackgroundImageError: (exception, stackTrace) {}, + child: Text( + psychologist.name.substring(0, 2).toUpperCase(), + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), ), ), - ), - ], - ), - const SizedBox(width: 14), + if (psychologist.availableOnline) + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 13, + height: 13, + decoration: BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 14), - // Name, Title, and Verification Badge - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - psychologist.name, - style: GoogleFonts.plusJakartaSans( - fontSize: 15, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - psychologist.title, - style: GoogleFonts.plusJakartaSans( - fontSize: 12, - color: AppColors.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 6), - Row( + // Name, Practitioner Badge, Title & Rating + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: const Color(0xFFBFDBFE)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.verified, size: 12, color: Color(0xFF2563EB)), - const SizedBox(width: 4), - Text( - psychologist.isPsychiatrist ? 'STR Terdaftar' : 'SIPP Terdaftar', - style: GoogleFonts.plusJakartaSans( - fontSize: 10, - fontWeight: FontWeight.w700, - color: const Color(0xFF1E40AF), + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (psychologist.isVolunteer) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFF0F9FF), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: const Color(0xFFBAE6FD)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.volunteer_activism_rounded, + size: 11, + color: Color(0xFF0EA5E9), + ), + const SizedBox(width: 4), + Text( + 'Relawan', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF0369A1), + ), + ), + ], ), ), - ], + if (psychologist.hasPsychologistCertificate) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.school_rounded, + size: 11, + color: Color(0xFF6366F1), + ), + const SizedBox(width: 4), + Text( + 'Profesional', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF3730A3), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + psychologist.name, + style: GoogleFonts.plusJakartaSans( + fontSize: 14.5, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - const SizedBox(width: 8), + const SizedBox(height: 2), + Text( + psychologist.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 11.5, + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), Row( children: [ - const Icon(Icons.star, size: 14, color: Color(0xFFF59E0B)), + const Icon(Icons.star_rounded, size: 15, color: Color(0xFFF59E0B)), const SizedBox(width: 3), Text( '${psychologist.rating.toStringAsFixed(1)} (${psychologist.reviewCount})', style: GoogleFonts.plusJakartaSans( fontSize: 11.5, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), + const SizedBox(width: 8), + Flexible( + child: Text( + '• ${psychologist.experienceYears} Thn Pengalaman', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ), ], ), ], ), - ], - ), + ), + ], ), - ], - ), - const SizedBox(height: 12), + const SizedBox(height: 12), - // Specialties Tags (Chips) - Wrap( - spacing: 6, - runSpacing: 4, - children: psychologist.specialties.take(3).map((specialty) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.primaryContainer.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(6), + // Patient Review Tags (Intelligent 2-row bin-packing with positive & negative sentiment) + if (psychologist.effectiveReviewTags.isNotEmpty) ...[ + DenseReviewTagsWrap( + tags: psychologist.effectiveReviewTags, + maxRows: 2, ), - child: Text( - '#$specialty', - style: GoogleFonts.plusJakartaSans( - fontSize: 11, - fontWeight: FontWeight.w500, - color: AppColors.primary, - ), - ), - ); - }).toList(), - ), - const SizedBox(height: 12), + const SizedBox(height: 12), + ], - const Divider(height: 1, color: Color(0xFFF1F5F9)), - const SizedBox(height: 12), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 12), - // Pricing & Booking Action - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, + // Pricing & Booking Action + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Biaya Sesi (${psychologist.durationMinutes} mnt)', - style: GoogleFonts.plusJakartaSans( - fontSize: 10.5, - color: AppColors.textSecondary, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Biaya Sesi (${psychologist.durationMinutes} mnt)', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.plusJakartaSans( + fontSize: 10.5, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 3), + Row( + children: [ + if (psychologist.coinsPerSession > 0) ...[ + Container( + padding: const EdgeInsets.all(3), + decoration: const BoxDecoration( + color: Color(0xFFFEF3C7), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.monetization_on_rounded, + size: 14, + color: Color(0xFFD97706), + ), + ), + const SizedBox(width: 5), + ], + Text( + psychologist.coinsPerSession > 0 + ? '${psychologist.coinsPerSession} Koin' + : 'Gratis', + style: GoogleFonts.plusJakartaSans( + fontSize: 15.5, + fontWeight: FontWeight.w800, + color: psychologist.coinsPerSession > 0 + ? AppColors.textPrimary + : const Color(0xFF0D9488), + ), + ), + if (psychologist.coinsPerSession > 0) + Text( + ' / sesi', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ], ), ), - const SizedBox(height: 1), - Text( - _formatCurrency(psychologist.feePerSession), - style: GoogleFonts.plusJakartaSans( - fontSize: 15, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, + const SizedBox(width: 8), + ElevatedButton( + onPressed: onBookTap, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), + child: Text( + 'Jadwalkan', + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), ), ), ], ), - ElevatedButton( - onPressed: onBookTap, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - elevation: 0, - ), - child: Text( - 'Jadwalkan', - style: GoogleFonts.plusJakartaSans( - fontSize: 12.5, - fontWeight: FontWeight.w700, - ), - ), - ), ], ), - ], + ), ), ); } diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_certificate_carousel.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_certificate_carousel.dart new file mode 100644 index 0000000..77d10dd --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_certificate_carousel.dart @@ -0,0 +1,472 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/psychologist_entity.dart'; + +/// Clean certificate carousel displaying the certificate photo and label directly. +/// Free from artificial marketing slogans or redundant placeholder labels. +class PsychologistCertificateCarousel extends StatefulWidget { + final List? certificateItems; + final List? certifications; + + const PsychologistCertificateCarousel({ + super.key, + this.certificateItems, + this.certifications, + }); + + @override + State createState() => + _PsychologistCertificateCarouselState(); +} + +class _PsychologistCertificateCarouselState + extends State { + late final PageController _pageController; + int _currentIndex = 0; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + List get _items { + if (widget.certificateItems != null && widget.certificateItems!.isNotEmpty) { + return widget.certificateItems!; + } + final certTitles = widget.certifications ?? []; + return certTitles.map((title) { + final lower = title.toLowerCase(); + String url; + if (lower.contains('sipp')) { + url = 'https://images.unsplash.com/photo-1614036417651-efe5912149d8?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('magister') || lower.contains('s2')) { + url = 'https://images.unsplash.com/photo-1659080928170-b9924d616f04?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('cbt') || lower.contains('sfbt') || lower.contains('mbsr')) { + url = 'https://images.unsplash.com/photo-1659080907100-23f0dac0fe27?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('pfa') || lower.contains('first aid')) { + url = 'https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=800&auto=format&fit=crop&q=80'; + } else if (lower.contains('peer') || lower.contains('sebaya') || lower.contains('safe space')) { + url = 'https://images.unsplash.com/photo-1450133064473-71024230f91b?w=800&auto=format&fit=crop&q=80'; + } else { + url = 'https://images.unsplash.com/photo-1523240795612-9a054b0db644?w=800&auto=format&fit=crop&q=80'; + } + return CertificateItem(title: title, imageUrl: url); + }).toList(); + } + + void _onPageChanged(int index) { + setState(() { + _currentIndex = index; + }); + } + + void _goToPrevious() { + if (_currentIndex > 0) { + _pageController.previousPage( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + } + + void _goToNext(int total) { + if (_currentIndex < total - 1) { + _pageController.nextPage( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + } + + void _showFullScreenPreview(BuildContext context, String imageUrl, String title) { + showDialog( + context: context, + barrierColor: Colors.black87, + builder: (ctx) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + title, + style: GoogleFonts.plusJakartaSans( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, color: Colors.white, size: 24), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: InteractiveViewer( + maxScale: 4.0, + child: Image.network( + imageUrl, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) => Container( + height: 260, + color: Colors.white, + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + title, + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Cubit / geser untuk memperbesar foto', + style: GoogleFonts.plusJakartaSans( + color: Colors.white70, + fontSize: 11, + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final items = _items; + + if (items.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + const Icon( + Icons.info_outline_rounded, + size: 18, + color: AppColors.textLight, + ), + const SizedBox(width: 8), + Text( + 'Belum ada data sertifikasi.', + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon( + Icons.workspace_premium_rounded, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 6), + Text( + 'Sertifikat & Lisensi', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${_currentIndex + 1} / ${items.length}', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Carousel Container (PageView) + SizedBox( + height: 220, + child: PageView.builder( + controller: _pageController, + physics: const BouncingScrollPhysics(), + itemCount: items.length, + onPageChanged: _onPageChanged, + itemBuilder: (context, index) { + final cert = items[index]; + return _buildPhotoAndLabelCard(cert); + }, + ), + ), + + const SizedBox(height: 10), + + // Navigation Bar: Chevron Left, Dots, Chevron Right + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Left Chevron Arrow + InkWell( + onTap: _currentIndex > 0 ? _goToPrevious : null, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: _currentIndex > 0 + ? const Color(0xFFF1F5F9) + : const Color(0xFFF8FAFC), + shape: BoxShape.circle, + border: Border.all( + color: _currentIndex > 0 + ? const Color(0xFFCBD5E1) + : const Color(0xFFE2E8F0), + ), + ), + child: Icon( + Icons.chevron_left_rounded, + size: 18, + color: _currentIndex > 0 + ? AppColors.textPrimary + : AppColors.textLight.withValues(alpha: 0.35), + ), + ), + ), + + // Dot Indicators + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(items.length, (dotIdx) { + final isActive = dotIdx == _currentIndex; + return GestureDetector( + onTap: () { + _pageController.animateToPage( + dotIdx, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: isActive ? 18 : 6, + height: 6, + decoration: BoxDecoration( + color: isActive + ? AppColors.primary + : const Color(0xFFCBD5E1), + borderRadius: BorderRadius.circular(3), + ), + ), + ); + }), + ), + + // Right Chevron Arrow + InkWell( + onTap: _currentIndex < items.length - 1 ? () => _goToNext(items.length) : null, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: _currentIndex < items.length - 1 + ? const Color(0xFFF1F5F9) + : const Color(0xFFF8FAFC), + shape: BoxShape.circle, + border: Border.all( + color: _currentIndex < items.length - 1 + ? const Color(0xFFCBD5E1) + : const Color(0xFFE2E8F0), + ), + ), + child: Icon( + Icons.chevron_right_rounded, + size: 18, + color: _currentIndex < items.length - 1 + ? AppColors.textPrimary + : AppColors.textLight.withValues(alpha: 0.35), + ), + ), + ), + ], + ), + ], + ); + } + + Widget _buildPhotoAndLabelCard(CertificateItem cert) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Foto Sertifikat + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(13)), + child: GestureDetector( + onTap: () => _showFullScreenPreview(context, cert.imageUrl, cert.title), + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + cert.imageUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: const Color(0xFFF8FAFC), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.workspace_premium_rounded, + size: 36, + color: Color(0xFF94A3B8), + ), + const SizedBox(height: 6), + Text( + 'Dokumen Sertifikat', + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + color: const Color(0xFFF8FAFC), + child: const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + }, + ), + + // Tap to zoom hint overlay + Positioned( + bottom: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3.5), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.zoom_in_rounded, + size: 13, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + 'Perbesar', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + + // 2. Label Terkait Sertifikat + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Text( + cert.title, + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + height: 1.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_role_badge.dart b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_role_badge.dart new file mode 100644 index 0000000..de10477 --- /dev/null +++ b/apps/eva_mobile/lib/features/care/presentation/widgets/psychologist_role_badge.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../theme/app_colors.dart'; +import '../../domain/entities/psychologist_entity.dart'; + +/// Reusable credential & role badge representing Volunteer and/or Certified Psychologist. +/// Aligned with the portal & mobile shield medallion design system. +class PsychologistRoleBadge extends StatelessWidget { + final PsychologistEntity psychologist; + final bool showExplanationOnTap; + + const PsychologistRoleBadge({ + super.key, + required this.psychologist, + this.showExplanationOnTap = true, + }); + + void _showQualificationModal(BuildContext context) { + if (!showExplanationOnTap) return; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + + // Header Row + Row( + children: [ + _buildMedallionIcons(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + psychologist.badgeHeaderTitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + _buildBadgeChips(), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 14), + + // Layer Explanations + Text( + 'Tingkat Kualifikasi & Kredensial', + style: GoogleFonts.plusJakartaSans( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + + if (psychologist.isVolunteer) ...[ + _buildLayerItem( + number: '1', + title: 'Relawan', + description: psychologist.hasPsychologistCertificate + ? 'Aktif memberikan sesi curhat & pendampingan sebaya bebas biaya (Gratis) untuk masyarakat.' + : 'Relawan terlatih di bidang Psychological First Aid (PFA) & konseling sebaya. Bebas biaya / Gratis.', + bgColor: const Color(0xFF0EA5E9), + ), + const SizedBox(height: 10), + ], + + if (psychologist.hasPsychologistCertificate) ...[ + _buildLayerItem( + number: psychologist.isVolunteer ? '2' : '1', + title: 'Profesional', + description: + 'Lulusan Magister Profesi Psikologi (S2) dengan Surat Izin Praktik Psikologi (SIPP) resmi: ${psychologist.registrationNumber}.', + bgColor: const Color(0xFF6366F1), + ), + const SizedBox(height: 10), + ], + + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 11), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + 'Mengerti', + style: GoogleFonts.plusJakartaSans( + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLayerItem({ + required String number, + required String title, + required String description, + required Color bgColor, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 20, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: bgColor, + shape: BoxShape.circle, + ), + child: Text( + number, + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.plusJakartaSans( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: GoogleFonts.plusJakartaSans( + fontSize: 11, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildMedallionIcons() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (psychologist.isVolunteer) + Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + color: Color(0xFF0EA5E9), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Color(0x330EA5E9), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.volunteer_activism_rounded, + color: Colors.white, + size: 15, + ), + ), + if (psychologist.hasBothBadges) const SizedBox(width: 5), + if (psychologist.hasPsychologistCertificate) + Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF6366F1), Color(0xFF4338CA)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Color(0x336366F1), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.school_rounded, + color: Colors.white, + size: 15, + ), + ), + ], + ); + } + + Widget _buildBadgeChips() { + return Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (psychologist.isVolunteer) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + 'Relawan', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w600, + color: const Color(0xFF64748B), + ), + ), + ), + if (psychologist.hasPsychologistCertificate) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + 'Profesional', + style: GoogleFonts.plusJakartaSans( + fontSize: 10, + fontWeight: FontWeight.w700, + color: const Color(0xFF3730A3), + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: showExplanationOnTap ? () => _showQualificationModal(context) : null, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + _buildMedallionIcons(), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + psychologist.badgeHeaderTitle, + style: GoogleFonts.plusJakartaSans( + fontSize: 12.5, + fontWeight: FontWeight.w800, + color: const Color(0xFF1E293B), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (showExplanationOnTap) + const Icon( + Icons.info_outline_rounded, + size: 13, + color: Color(0xFF94A3B8), + ), + ], + ), + const SizedBox(height: 4), + _buildBadgeChips(), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/chat/presentation/screens/ai_conversation_screen.dart b/apps/eva_mobile/lib/features/chat/presentation/screens/ai_conversation_screen.dart index 6638c8c..4cb9264 100644 --- a/apps/eva_mobile/lib/features/chat/presentation/screens/ai_conversation_screen.dart +++ b/apps/eva_mobile/lib/features/chat/presentation/screens/ai_conversation_screen.dart @@ -4,6 +4,10 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:eva_mobile/features/chat/presentation/widgets/animated_session_card.dart'; import 'package:eva_mobile/features/chat/presentation/widgets/session_list_skeleton.dart'; +import 'package:eva_mobile/features/coin/presentation/providers/coin_provider.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/presentation/providers/tutorial_provider.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; import 'package:eva_mobile/theme/app_colors.dart'; import 'package:eva_mobile/widgets/app_back_button.dart'; import 'package:eva_mobile/widgets/custom_button.dart'; @@ -20,6 +24,8 @@ class AiConversationScreen extends ConsumerStatefulWidget { class _AiConversationScreenState extends ConsumerState { final ScrollController _scrollController = ScrollController(); + final GlobalKey _coinBadgeKey = GlobalKey(debugLabel: 'coinBadge'); + final GlobalKey _startVoiceButtonKey = GlobalKey(debugLabel: 'startVoiceButton'); List> _conversations = []; bool _isLoading = true; @@ -93,109 +99,166 @@ class _AiConversationScreenState extends ConsumerState { 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), + Future _handleStartVoiceSession() async { + final tutorialState = ref.read(tutorialNotifierProvider); + if (tutorialState.isActive && + tutorialState.currentStep == TutorialStep.voiceStart) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceStart); + } + await Navigator.pushNamed(context, '/voice_call'); + if (mounted) { + _fetchConversations(isRefresh: true); + } + } + Widget _buildCoinBadge(int coinBalance) { + return Material( + color: Colors.transparent, + child: InkWell( + key: _coinBadgeKey, + borderRadius: BorderRadius.circular(20), + onTap: () { + final tutorialState = ref.read(tutorialNotifierProvider); + if (tutorialState.isActive && + tutorialState.currentStep == TutorialStep.voiceCoins) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceCoins); + return; + } + // TODO: Navigasi ke page pembayaran setelah page pembayaran selesai dibangun + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.15), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), ], - - begin: Alignment.topCenter, - - end: Alignment.bottomCenter, - ), - - ), - - child: SafeArea( - - child: Column( - + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - - // Header Bar - - Padding( - - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), - - child: Row( - - children: [ - - const AppBackButton(), - const SizedBox(width: 10), - - 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, - - ); - - }, - + // Coin Icon with '+' badge + Stack( + clipBehavior: Clip.none, + children: [ + Container( + padding: const EdgeInsets.all(2.5), + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + Color(0xFFFFD54F), + Color(0xFFFFB300), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), ), - - const SizedBox(width: 8), - - Text( - - 'Sesi Suara EVA', - - style: GoogleFonts.inter( - - fontSize: 16, - - fontWeight: FontWeight.w700, - + child: const Icon( + Icons.monetization_on_rounded, + color: Colors.white, + size: 15, + ), + ), + Positioned( + right: -3, + bottom: -3, + child: Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( color: AppColors.primary, - + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.2), + ), + child: const Icon( + Icons.add, + size: 8, + color: Colors.white, ), - ), + ), + ], + ), + const SizedBox(width: 8), + Text( + '$coinBalance', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.primary, + letterSpacing: -0.2, + ), + ), + ], + ), + ), + ), + ); + } - ], + @override + Widget build(BuildContext context) { + final tutorialState = ref.watch(tutorialNotifierProvider); + final coinBalance = ref.watch(coinBalanceProvider); - ), + final bool showTutorialOverlay = tutorialState.isActive && + (tutorialState.currentStep == TutorialStep.voiceCoins || + tutorialState.currentStep == TutorialStep.voiceStart); + return Scaffold( + body: Stack( + children: [ + Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFF6F8FF), + Color(0xFFEFF2FE), + Color(0xFFF8F9FE), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ), - - const Divider(height: 1, color: Color(0xFFEBECEF)), + ), + child: SafeArea( + child: Column( + children: [ + // Header Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Text( + 'Sesi Suara EVA', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + const Spacer(), + _buildCoinBadge(coinBalance), + ], + ), + ), + const Divider(height: 1, color: Color(0xFFEBECEF)), @@ -305,14 +368,10 @@ class _AiConversationScreenState extends ConsumerState { StaggeredEntrance( index: 2, child: CustomPillButton( + key: _startVoiceButtonKey, text: 'Mulai Sesi Suara', suffixIcon: Icons.mic, - onPressed: () async { - await Navigator.pushNamed(context, '/voice_call'); - if (mounted) { - _fetchConversations(isRefresh: true); - } - }, + onPressed: () => _handleStartVoiceSession(), ), ), const SizedBox(height: 36), @@ -393,17 +452,41 @@ class _AiConversationScreenState extends ConsumerState { ), ), ), - - ], - - ), - + ], ), - ), - + ), + if (showTutorialOverlay) + InteractiveSpotlightOverlay( + key: ValueKey(tutorialState.currentStep), + targetKey: tutorialState.currentStep == TutorialStep.voiceCoins + ? _coinBadgeKey + : _startVoiceButtonKey, + content: TutorialContent.stepContents[tutorialState.currentStep!]!, + onSkip: () => + ref.read(tutorialNotifierProvider.notifier).skipTutorial(), + onNext: () { + if (tutorialState.currentStep == TutorialStep.voiceCoins) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceCoins); + } else if (tutorialState.currentStep == TutorialStep.voiceStart) { + _handleStartVoiceSession(); + } + }, + onTargetTap: () { + if (tutorialState.currentStep == TutorialStep.voiceCoins) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceCoins); + } else if (tutorialState.currentStep == TutorialStep.voiceStart) { + _handleStartVoiceSession(); + } + }, + ), + ], +), ); - } Widget _buildSessionCard(BuildContext context, Map conv) { diff --git a/apps/eva_mobile/lib/features/coin/domain/entities/coin_package_entity.dart b/apps/eva_mobile/lib/features/coin/domain/entities/coin_package_entity.dart new file mode 100644 index 0000000..5ef8081 --- /dev/null +++ b/apps/eva_mobile/lib/features/coin/domain/entities/coin_package_entity.dart @@ -0,0 +1,32 @@ +class CoinPackageEntity { + final String id; + final String title; + final int coins; + final int bonusCoins; + final int priceIdr; + final String? badge; + final bool isPopular; + final String description; + + const CoinPackageEntity({ + required this.id, + required this.title, + required this.coins, + this.bonusCoins = 0, + required this.priceIdr, + this.badge, + this.isPopular = false, + this.description = '', + }); + + int get totalCoins => coins + bonusCoins; + + String get formattedPrice { + final str = priceIdr.toString(); + final formatted = str.replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (match) => '${match[1]}.', + ); + return 'Rp $formatted'; + } +} diff --git a/apps/eva_mobile/lib/features/coin/presentation/providers/coin_provider.dart b/apps/eva_mobile/lib/features/coin/presentation/providers/coin_provider.dart new file mode 100644 index 0000000..84a450c --- /dev/null +++ b/apps/eva_mobile/lib/features/coin/presentation/providers/coin_provider.dart @@ -0,0 +1,113 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../domain/entities/coin_package_entity.dart'; + +/// Predefined coin packages available for purchase in Eva Mobile. +final coinPackagesProvider = Provider>((ref) { + return const [ + CoinPackageEntity( + id: 'pkg_starter', + title: 'Paket Starter', + coins: 50, + bonusCoins: 0, + priceIdr: 15000, + description: 'Cocok untuk mencoba berbicara dengan EVA', + ), + CoinPackageEntity( + id: 'pkg_popular', + title: 'Paket Favorit', + coins: 150, + bonusCoins: 20, + priceIdr: 45000, + badge: 'Paling Populer', + isPopular: true, + description: 'Pilihan hemat untuk bicara rutin & konsultasi psikolog', + ), + CoinPackageEntity( + id: 'pkg_pro', + title: 'Paket Pro Mind', + coins: 300, + bonusCoins: 60, + priceIdr: 89000, + badge: 'Hemat 25%', + description: 'Bicara intensif dengan EVA & konsultasi psikolog ahli', + ), + CoinPackageEntity( + id: 'pkg_ultimate', + title: 'Paket Ultimate', + coins: 600, + bonusCoins: 150, + priceIdr: 169000, + badge: 'Super Hemat', + description: 'Koin terlengkap untuk bicara dengan EVA & konsultasi psikolog', + ), + ]; +}); + +/// Key used to persist user's coin balance locally. +const String _kUserCoinBalanceKey = 'user_coin_balance'; +const int kDefaultCoinBalance = 120; + +/// StateNotifier that manages user's reactive coin balance. +class CoinBalanceNotifier extends StateNotifier { + CoinBalanceNotifier() : super(kDefaultCoinBalance) { + _loadInitialBalance(); + } + + Future _loadInitialBalance() async { + try { + final prefs = await SharedPreferences.getInstance(); + final savedBalance = prefs.getInt(_kUserCoinBalanceKey); + if (savedBalance != null) { + state = savedBalance; + } else { + await prefs.setInt(_kUserCoinBalanceKey, kDefaultCoinBalance); + } + } catch (_) { + // Fallback to default in case of error + } + } + + /// Add purchased or reward coins + Future addCoins(int amount) async { + if (amount <= 0) return; + final newBalance = state + amount; + state = newBalance; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_kUserCoinBalanceKey, newBalance); + } catch (_) {} + } + + /// Spend coins for voice sessions or premium features + Future spendCoins(int amount) async { + if (amount <= 0 || state < amount) return false; + final newBalance = state - amount; + state = newBalance; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_kUserCoinBalanceKey, newBalance); + } catch (_) {} + return true; + } + + /// Set balance directly (e.g., from server sync) + Future setBalance(int balance) async { + state = balance; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_kUserCoinBalanceKey, balance); + } catch (_) {} + } + + /// Refresh from storage + Future refresh() async { + await _loadInitialBalance(); + } +} + +/// Global provider for user's coin balance +final coinBalanceProvider = StateNotifierProvider((ref) { + return CoinBalanceNotifier(); +}); diff --git a/apps/eva_mobile/lib/features/coin/presentation/screens/coin_purchase_screen.dart b/apps/eva_mobile/lib/features/coin/presentation/screens/coin_purchase_screen.dart new file mode 100644 index 0000000..15e5976 --- /dev/null +++ b/apps/eva_mobile/lib/features/coin/presentation/screens/coin_purchase_screen.dart @@ -0,0 +1,804 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/widgets/app_back_button.dart'; +import 'package:eva_mobile/widgets/app_snack_bar.dart'; +import 'package:eva_mobile/widgets/custom_button.dart'; +import 'package:eva_mobile/widgets/glass_card.dart'; +import '../../domain/entities/coin_package_entity.dart'; +import '../providers/coin_provider.dart'; + +/// Screen for browsing, selecting, and purchasing Eva AI coins. +class CoinPurchaseScreen extends ConsumerStatefulWidget { + const CoinPurchaseScreen({super.key}); + + @override + ConsumerState createState() => _CoinPurchaseScreenState(); +} + +class _CoinPurchaseScreenState extends ConsumerState { + String _selectedPackageId = 'pkg_popular'; + String _selectedPaymentMethod = 'qris'; + bool _isProcessing = false; + + final List> _paymentMethods = [ + { + 'id': 'qris', + 'name': 'QRIS (Instant)', + 'desc': 'GoPay, OVO, DANA, ShopeePay & M-Banking', + 'icon': Icons.qr_code_2_rounded, + }, + { + 'id': 'va', + 'name': 'Virtual Account Bank', + 'desc': 'BCA, Mandiri, BRI, BNI & Permata', + 'icon': Icons.account_balance_rounded, + }, + { + 'id': 'gplay', + 'name': 'Google Play Billing', + 'desc': 'Saldo Google Play & Tagihan Operator', + 'icon': Icons.shop_two_rounded, + }, + ]; + + Future _handlePurchase(CoinPackageEntity package) async { + setState(() => _isProcessing = true); + + // Simulate instant secure payment processing + await Future.delayed(const Duration(milliseconds: 900)); + + if (!mounted) return; + + await ref.read(coinBalanceProvider.notifier).addCoins(package.totalCoins); + + setState(() => _isProcessing = false); + + if (!mounted) return; + + _showSuccessDialog(package); + } + + void _showSuccessDialog(CoinPackageEntity package) { + final currentBalance = ref.read(coinBalanceProvider); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(height: 24), + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFF3F4FE), + border: Border.all( + color: AppColors.primaryContainer, + width: 2, + ), + ), + child: const Center( + child: Icon( + Icons.check_circle_rounded, + size: 46, + color: AppColors.primary, + ), + ), + ), + const SizedBox(height: 18), + Text( + 'Pembelian Berhasil!', + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'Selamat! ${package.totalCoins} Koin telah ditambahkan ke akunmu.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Saldo Koin Sekarang:', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + Row( + children: [ + const Icon( + Icons.monetization_on_rounded, + size: 18, + color: Color(0xFFFFD54F), + ), + const SizedBox(width: 6), + Text( + '$currentBalance Koin', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.primary, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 26), + CustomButton( + text: 'Gunakan Koin Sekarang', + onPressed: () { + Navigator.pop(ctx); + AppSnackBar.showSuccess( + context, + 'Koin siap digunakan untuk berbicara dengan EVA & konsultasi psikolog!', + ); + }, + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final coinBalance = ref.watch(coinBalanceProvider); + final packages = ref.watch(coinPackagesProvider); + final selectedPackage = packages.firstWhere( + (pkg) => pkg.id == _selectedPackageId, + orElse: () => packages.first, + ); + + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + children: [ + // 1. Top Navigation Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const AppBackButton(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Beli Koin EVA', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + 'Top up koin untuk berbicara dengan EVA & konsultasi psikolog', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + + // 2. Scrollable Content + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Balance Hero Card (Theme Purple Gradient) + _buildBalanceHeroCard(coinBalance), + + const SizedBox(height: 24), + + // Section: Pilih Paket Koin + Text( + 'PILIH PAKET KOIN', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 12), + + // Packages Grid + _buildPackagesGrid(packages), + + const SizedBox(height: 24), + + // Section: Metode Pembayaran + Text( + 'METODE PEMBAYARAN', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 12), + + _buildPaymentMethodsList(), + + const SizedBox(height: 24), + + // Section: Manfaat Koin + _buildBenefitsCard(), + + const SizedBox(height: 32), + ], + ), + ), + ), + + // 3. Sticky Bottom Checkout Bar + _buildStickyBottomBar(selectedPackage), + ], + ), + ), + ); + } + + Widget _buildBalanceHeroCard(int coinBalance) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF5358CB), // AppColors.primary + Color(0xFF8B93FF), // AppColors.primaryLight + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5358CB).withValues(alpha: 0.28), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.18), + border: Border.all( + color: Colors.white.withValues(alpha: 0.35), + width: 1.5, + ), + ), + child: const Center( + child: Icon( + Icons.monetization_on_rounded, + size: 30, + color: Color(0xFFFFD54F), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Saldo Koin Kamu', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: 2), + Text( + '$coinBalance Koin', + style: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w900, + color: Colors.white, + letterSpacing: -0.5, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.verified_rounded, + size: 14, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + 'Aktif', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPackagesGrid(List packages) { + return Column( + children: packages.map((pkg) { + final isSelected = pkg.id == _selectedPackageId; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: () { + setState(() => _selectedPackageId = pkg.id); + }, + borderRadius: BorderRadius.circular(18), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFFF3F4FE) : Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFE2E8F0), + width: isSelected ? 2 : 1, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.12), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ] + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? AppColors.primary.withValues(alpha: 0.12) + : const Color(0xFFF8F9FE), + ), + child: Center( + child: Icon( + Icons.toll_rounded, + size: 24, + color: isSelected ? AppColors.primary : const Color(0xFFFFB300), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 3, + children: [ + Text( + pkg.title, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + if (pkg.badge != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 2, + ), + decoration: BoxDecoration( + color: pkg.isPopular + ? const Color(0xFFFFF7ED) + : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: pkg.isPopular + ? const Color(0xFFFDBA74) + : const Color(0xFFC7D2FE), + width: 0.8, + ), + ), + child: Text( + pkg.badge!, + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w800, + color: pkg.isPopular + ? const Color(0xFFC2410C) + : AppColors.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 2, + children: [ + Text( + '${pkg.totalCoins} Koin', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: isSelected ? AppColors.primary : AppColors.textPrimary, + ), + ), + if (pkg.bonusCoins > 0) + Text( + '(+${pkg.bonusCoins} Bonus)', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF10B981), + ), + ), + ], + ), + if (pkg.description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + pkg.description, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + pkg.formattedPrice, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Icon( + isSelected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_off_rounded, + size: 20, + color: isSelected ? AppColors.primary : const Color(0xFFCBD5E1), + ), + ], + ), + ], + ), + ), + ), + ); + }).toList(), + ); + } + + Widget _buildPaymentMethodsList() { + return Column( + children: _paymentMethods.map((method) { + final isSelected = method['id'] == _selectedPaymentMethod; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: InkWell( + onTap: () { + setState(() => _selectedPaymentMethod = method['id'] as String); + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFE2E8F0), + width: isSelected ? 1.5 : 1, + ), + ), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: isSelected + ? AppColors.primaryContainer + : const Color(0xFFF8F9FE), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Icon( + method['icon'] as IconData, + size: 20, + color: isSelected ? AppColors.primary : const Color(0xFF64748B), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + method['name'] as String, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + Text( + method['desc'] as String, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Icon( + isSelected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_off_rounded, + size: 18, + color: isSelected ? AppColors.primary : const Color(0xFFCBD5E1), + ), + ], + ), + ), + ), + ); + }).toList(), + ); + } + + Widget _buildBenefitsCard() { + return GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.stars_rounded, + size: 20, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Text( + 'Manfaat Koin EVA', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 14), + _buildBenefitItem( + icon: Icons.record_voice_over_rounded, + title: 'Bisa Berbicara Langsung dengan EVA', + desc: + 'Dapat digunakan untuk sesi panggilan suara (Voice Call) dan interaksi curhat real-time dua arah bersama AI EVA.', + ), + const SizedBox(height: 12), + _buildBenefitItem( + icon: Icons.psychology_rounded, + title: 'Alat Transaksi Konsultasi Psikolog', + desc: + 'Dapat digunakan sebagai alat transaksi untuk menjadwalkan dan berkonsultasi langsung dengan psikolog pada halaman Layanan.', + ), + ], + ), + ); + } + + Widget _buildBenefitItem({ + required IconData icon, + required String title, + required String desc, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 14, color: AppColors.primary), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + desc, + style: GoogleFonts.inter( + fontSize: 11.5, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildStickyBottomBar(CoinPackageEntity selectedPackage) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + border: const Border( + top: BorderSide(color: Color(0xFFE2E8F0), width: 1), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, -3), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Total Pembayaran', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + selectedPackage.formattedPrice, + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: CustomButton( + text: 'Beli Sekarang', + isLoading: _isProcessing, + onPressed: _isProcessing ? null : () => _handlePurchase(selectedPackage), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart b/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart index 2fc487a..a599767 100644 --- a/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart +++ b/apps/eva_mobile/lib/features/dass/presentation/screens/dass_assessment_screen.dart @@ -13,6 +13,9 @@ import '../widgets/dass_question_card.dart'; import '../widgets/dass_skeleton_loader.dart'; import '../widgets/dass_subscale_info_sheet.dart'; import '../widgets/dass_subscales_card.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/presentation/providers/tutorial_provider.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; /// Screen for displaying, reviewing, and verifying DASS-21 psychological self-assessments /// with period filtering, explainable AI accordion insights, and 3-subscale evaluations. @@ -29,6 +32,9 @@ class DASSAssessmentScreen extends ConsumerStatefulWidget { } class _DASSAssessmentScreenState extends ConsumerState { + final GlobalKey _subscalesCardKey = GlobalKey(debugLabel: 'dassSubscalesCard'); + final GlobalKey _firstDassItemKey = GlobalKey(debugLabel: 'firstDassItemCard'); + final ScrollController _dassScrollController = ScrollController(); late String _currentPeriod; String _selectedFilter = 'all'; // 'all', 'stress', 'anxiety', 'depression' final Set _expandedItemIds = {}; @@ -40,6 +46,46 @@ class _DASSAssessmentScreenState extends ConsumerState { void initState() { super.initState(); _currentPeriod = widget.initialPeriod; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final tutorialState = ref.read(tutorialNotifierProvider); + if (tutorialState.isActive && + tutorialState.currentStep == TutorialStep.dassItem) { + _scrollToFirstDassItem(); + } + }); + } + + @override + void dispose() { + _dassScrollController.dispose(); + super.dispose(); + } + + void _scrollToFirstDassItem() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_firstDassItemKey.currentContext != null) { + Scrollable.ensureVisible( + _firstDassItemKey.currentContext!, + alignment: 0.05, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + ); + } else { + Future.delayed(const Duration(milliseconds: 120), () { + if (!mounted) return; + if (_firstDassItemKey.currentContext != null) { + Scrollable.ensureVisible( + _firstDassItemKey.currentContext!, + alignment: 0.05, + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + ); + } + }); + } + }); } Future _openWhatsappEmergency() async { @@ -65,6 +111,12 @@ class _DASSAssessmentScreenState extends ConsumerState { @override Widget build(BuildContext context) { + ref.listen(tutorialNotifierProvider, (previous, next) { + if (next.isActive && next.currentStep == TutorialStep.dassItem) { + _scrollToFirstDassItem(); + } + }); + final dassState = ref.watch(dassAssessmentNotifierProvider); final notifier = ref.read(dassAssessmentNotifierProvider.notifier); final isEditable = _currentPeriod == 'today'; @@ -83,6 +135,16 @@ class _DASSAssessmentScreenState extends ConsumerState { final bool showSaveBar = isEditable && currentAssessment != null && (hasChanges || isUnverified); + final tutorialState = ref.watch(tutorialNotifierProvider); + final bool isDassDetail = tutorialState.isActive && + tutorialState.currentStep == TutorialStep.dassDetail; + final bool isDassItem = tutorialState.isActive && + tutorialState.currentStep == TutorialStep.dassItem; + final bool showTutorial = isDassDetail || isDassItem; + final GlobalKey? activeTargetKey = isDassDetail + ? _subscalesCardKey + : (isDassItem ? _firstDassItemKey : null); + return Scaffold( body: Stack( children: [ @@ -113,7 +175,10 @@ class _DASSAssessmentScreenState extends ConsumerState { ), Expanded( child: dassState.assessment.when( - loading: () => const DassSkeletonLoader(), + loading: () => DassSkeletonLoader( + subscalesCardKey: _subscalesCardKey, + firstItemKey: _firstDassItemKey, + ), error: (err, _) => RefreshIndicator( onRefresh: () => notifier.loadTodayAssessment(), color: AppColors.primary, @@ -175,6 +240,7 @@ class _DASSAssessmentScreenState extends ConsumerState { backgroundColor: Colors.white, displacement: 24, child: ListView( + controller: _dassScrollController, physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), children: [ @@ -186,6 +252,7 @@ class _DASSAssessmentScreenState extends ConsumerState { StaggeredEntrance( index: 3, child: DassSubscalesCard( + key: _subscalesCardKey, assessment: assessment, stagedScores: staged, onInfoTap: () => DassSubscaleInfoSheet.showGuideDialog(context), @@ -227,6 +294,7 @@ class _DASSAssessmentScreenState extends ConsumerState { final item = filteredItems[index]; final currentScore = staged[item.itemId] ?? item.score; return DassQuestionCard( + key: index == 0 ? _firstDassItemKey : null, item: item, currentScore: currentScore, isEditable: isEditable, @@ -247,7 +315,7 @@ class _DASSAssessmentScreenState extends ConsumerState { }, ), ), - const SizedBox(height: 80), + const SizedBox(height: 320), ], ), ); @@ -277,6 +345,44 @@ class _DASSAssessmentScreenState extends ConsumerState { } }, ), + if (showTutorial && activeTargetKey != null) + InteractiveSpotlightOverlay( + key: ValueKey(tutorialState.currentStep), + targetKey: activeTargetKey, + content: TutorialContent.stepContents[tutorialState.currentStep!]!, + onSkip: () { + ref.read(tutorialNotifierProvider.notifier).skipTutorial(); + Navigator.pop(context); + }, + onNext: () { + final current = tutorialState.currentStep; + if (current == TutorialStep.dassDetail) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.dassDetail); + _scrollToFirstDassItem(); + } else if (current == TutorialStep.dassItem) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.dassItem); + Navigator.pop(context); + } + }, + onTargetTap: () { + final current = tutorialState.currentStep; + if (current == TutorialStep.dassDetail) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.dassDetail); + _scrollToFirstDassItem(); + } else if (current == TutorialStep.dassItem) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.dassItem); + Navigator.pop(context); + } + }, + ), ], ), ); diff --git a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_skeleton_loader.dart b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_skeleton_loader.dart index 5a19b72..951b580 100644 --- a/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_skeleton_loader.dart +++ b/apps/eva_mobile/lib/features/dass/presentation/widgets/dass_skeleton_loader.dart @@ -5,7 +5,13 @@ import '../../../../widgets/skeleton_shimmer.dart'; /// Shimmer skeleton loader for the DASS-21 Assessment Screen. class DassSkeletonLoader extends StatelessWidget { - const DassSkeletonLoader({super.key}); + final GlobalKey? subscalesCardKey; + final GlobalKey? firstItemKey; + const DassSkeletonLoader({ + super.key, + this.subscalesCardKey, + this.firstItemKey, + }); @override Widget build(BuildContext context) { @@ -17,8 +23,10 @@ class DassSkeletonLoader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // 1. Three Score Summary Cards (Depresi, Kecemasan, Stres) - Row( - children: List.generate( + Container( + key: subscalesCardKey, + child: Row( + children: List.generate( 3, (index) => Expanded( child: Container( @@ -42,6 +50,7 @@ class DassSkeletonLoader extends StatelessWidget { ), ), ), + ), const SizedBox(height: 18), // 2. Main Clinical Insight Card Skeleton @@ -82,6 +91,7 @@ class DassSkeletonLoader extends StatelessWidget { (index) => Padding( padding: const EdgeInsets.only(bottom: 12.0), child: GlassCard( + key: index == 0 ? firstItemKey : null, width: double.infinity, padding: const EdgeInsets.all(16), child: Row( diff --git a/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_screen.dart b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_screen.dart index 6c29220..e2b881d 100644 --- a/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_screen.dart +++ b/apps/eva_mobile/lib/features/diary/presentation/screens/ai_diary_screen.dart @@ -10,14 +10,20 @@ import 'package:eva_mobile/widgets/glass_card.dart'; import 'package:eva_mobile/widgets/skeleton_shimmer.dart'; class AiDiaryScreen extends ConsumerStatefulWidget { - const AiDiaryScreen({super.key}); + final GlobalKey? firstCardKey; + + const AiDiaryScreen({ + super.key, + this.firstCardKey, + }); @override ConsumerState createState() => AiDiaryScreenState(); } class AiDiaryScreenState extends ConsumerState { - + late final GlobalKey _defaultFirstCardKey = GlobalKey(debugLabel: 'firstJournalCard'); + GlobalKey get firstJournalCardKey => widget.firstCardKey ?? _defaultFirstCardKey; final TextEditingController _searchController = TextEditingController(); String _selectedFilter = 'Semua'; @@ -352,7 +358,7 @@ class AiDiaryScreenState extends ConsumerState { // Journal History Cards List / Skeleton Loader if (_isLoading && _entries.isEmpty) - const _DiaryListSkeleton() + _DiaryListSkeleton(firstCardKey: firstJournalCardKey) else if (filteredList.isEmpty) if (_entries.isEmpty && !_isLoading) Center( @@ -457,7 +463,7 @@ class AiDiaryScreenState extends ConsumerState { return GlassCard( - + key: index == 0 ? firstJournalCardKey : null, width: double.infinity, onTap: () { @@ -678,7 +684,8 @@ class AiDiaryScreenState extends ConsumerState { /// Shimmer skeleton loader for the AI Diary list. class _DiaryListSkeleton extends StatelessWidget { - const _DiaryListSkeleton(); + final GlobalKey? firstCardKey; + const _DiaryListSkeleton({this.firstCardKey}); @override Widget build(BuildContext context) { @@ -689,6 +696,7 @@ class _DiaryListSkeleton extends StatelessWidget { (index) => Padding( padding: const EdgeInsets.only(bottom: 14.0), child: GlassCard( + key: index == 0 ? firstCardKey : null, width: double.infinity, padding: const EdgeInsets.all(18), child: Column( diff --git a/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart b/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart index 5a85ebc..72cbda2 100644 --- a/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/eva_mobile/lib/features/home/presentation/screens/home_screen.dart @@ -393,6 +393,10 @@ class HomeScreenState extends ConsumerState with WidgetsBindingObser Navigator.pop(dialogContext); Navigator.pushNamed(context, '/settings'); }, + onCoinTap: () { + Navigator.pop(dialogContext); + Navigator.pushNamed(context, '/coin_purchase'); + }, onLogoutTap: () async { Navigator.pop(dialogContext); await AppConfig.clearToken(); diff --git a/apps/eva_mobile/lib/features/home/presentation/screens/main_shell_screen.dart b/apps/eva_mobile/lib/features/home/presentation/screens/main_shell_screen.dart index 5ef3f48..95199f3 100644 --- a/apps/eva_mobile/lib/features/home/presentation/screens/main_shell_screen.dart +++ b/apps/eva_mobile/lib/features/home/presentation/screens/main_shell_screen.dart @@ -51,6 +51,7 @@ class MainShellScreen extends ConsumerStatefulWidget { class _MainShellScreenState extends ConsumerState { late int _currentIndex; bool _isNavBarVisible = true; + bool _isPageAnimating = false; late final PageController _pageController; final GlobalKey _homeKey = GlobalKey(); @@ -59,6 +60,9 @@ class _MainShellScreenState extends ConsumerState { final GlobalKey _careKey = GlobalKey(); final GlobalKey _centerButtonKey = GlobalKey(debugLabel: 'centerButton'); + final GlobalKey _firstJournalCardKey = GlobalKey(debugLabel: 'firstJournalCard'); + final GlobalKey _monitoringDassCardKey = GlobalKey(debugLabel: 'monitoringDassCard'); + final GlobalKey _firstPsychologistCardKey = GlobalKey(debugLabel: 'firstPsychologistCard'); final GlobalKey _journalTabKey = GlobalKey(debugLabel: 'journalTab'); final GlobalKey _trenTabKey = GlobalKey(debugLabel: 'trenTab'); final GlobalKey _careTabKey = GlobalKey(debugLabel: 'careTab'); @@ -78,13 +82,22 @@ class _MainShellScreenState extends ConsumerState { ), ), _KeepAliveWrapper( - child: AiDiaryScreen(key: _diaryKey), + child: AiDiaryScreen( + key: _diaryKey, + firstCardKey: _firstJournalCardKey, + ), ), _KeepAliveWrapper( - child: MonitoringScreen(key: _monitoringKey), + child: MonitoringScreen( + key: _monitoringKey, + dassCardKey: _monitoringDassCardKey, + ), ), _KeepAliveWrapper( - child: CareScreen(key: _careKey), + child: CareScreen( + key: _careKey, + firstPsychologistCardKey: _firstPsychologistCardKey, + ), ), ]; @@ -111,12 +124,18 @@ class _MainShellScreenState extends ConsumerState { switch (step) { case TutorialStep.voiceSession: return _centerButtonKey; + case TutorialStep.voiceCoins: + case TutorialStep.voiceStart: + case TutorialStep.voiceCompleted: + case TutorialStep.dassDetail: + case TutorialStep.dassItem: + return null; case TutorialStep.journal: - return _journalTabKey; + return _firstJournalCardKey; case TutorialStep.analytics: - return _trenTabKey; + return _monitoringDassCardKey; case TutorialStep.care: - return _careTabKey; + return _firstPsychologistCardKey; } } @@ -141,16 +160,20 @@ class _MainShellScreenState extends ConsumerState { setState(() { _currentIndex = index; _isNavBarVisible = true; + _isPageAnimating = true; }); _pageController .animateToPage( index, - duration: const Duration(milliseconds: 250), + duration: const Duration(milliseconds: 280), curve: Curves.easeOutCubic, ) .then((_) { if (mounted) { + setState(() { + _isPageAnimating = false; + }); _triggerRefresh(index); } }); @@ -326,6 +349,21 @@ class _MainShellScreenState extends ConsumerState { Widget build(BuildContext context) { final tutorialState = ref.watch(tutorialNotifierProvider); + ref.listen(tutorialNotifierProvider, (previous, next) { + if (next.isActive) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (next.currentStep == TutorialStep.journal && _currentIndex != 1) { + _onTabTapped(1); + } else if (next.currentStep == TutorialStep.analytics && _currentIndex != 2) { + _onTabTapped(2); + } else if (next.currentStep == TutorialStep.care && _currentIndex != 3) { + _onTabTapped(3); + } + }); + } + }); + return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { @@ -397,28 +435,82 @@ class _MainShellScreenState extends ConsumerState { ), // Branded Guidance Card + Interactive Spotlight Overlay - if (tutorialState.isActive && tutorialState.currentStep != null) + if (tutorialState.isActive && + tutorialState.currentStep != null && + !_isPageAnimating && + _getTargetKeyForStep(tutorialState.currentStep!) != null) InteractiveSpotlightOverlay( key: ValueKey(tutorialState.currentStep), targetKey: _getTargetKeyForStep(tutorialState.currentStep!), + secondaryTargetKey: tutorialState.currentStep == TutorialStep.care + ? _careTabKey + : null, content: TutorialContent.stepContents[tutorialState.currentStep!]!, onSkip: () => ref.read(tutorialNotifierProvider.notifier).skipTutorial(), onNext: () { final current = tutorialState.currentStep; + if (current == TutorialStep.voiceSession) { + _handleCenterButtonTap(); + return; + } if (current == TutorialStep.journal) { - _onTabTapped(2); - } else if (current == TutorialStep.analytics) { - _onTabTapped(3); + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.journal); + return; + } + if (current == TutorialStep.analytics) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.analytics); + Navigator.pushNamed(context, '/dass_assessment').then((_) { + final tut = ref.read(tutorialNotifierProvider); + if (!tut.isCompleted && tut.currentStep == TutorialStep.care) { + _onTabTapped(3); + } + }); + return; + } + if (current == TutorialStep.care) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.care); + return; } ref.read(tutorialNotifierProvider.notifier).nextStep(); }, onTargetTap: () { - if (tutorialState.currentStep == TutorialStep.voiceSession) { + final current = tutorialState.currentStep; + if (current == TutorialStep.voiceSession) { _handleCenterButtonTap(); - } else { - ref.read(tutorialNotifierProvider.notifier).nextStep(); + return; + } + if (current == TutorialStep.journal) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.journal); + return; + } + if (current == TutorialStep.analytics) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.analytics); + Navigator.pushNamed(context, '/dass_assessment').then((_) { + final tut = ref.read(tutorialNotifierProvider); + if (!tut.isCompleted && tut.currentStep == TutorialStep.care) { + _onTabTapped(3); + } + }); + return; } + if (current == TutorialStep.care) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.care); + return; + } + ref.read(tutorialNotifierProvider.notifier).nextStep(); }, ), ], diff --git a/apps/eva_mobile/lib/features/home/presentation/widgets/profile_dropdown_modal.dart b/apps/eva_mobile/lib/features/home/presentation/widgets/profile_dropdown_modal.dart index 4072668..37695ac 100644 --- a/apps/eva_mobile/lib/features/home/presentation/widgets/profile_dropdown_modal.dart +++ b/apps/eva_mobile/lib/features/home/presentation/widgets/profile_dropdown_modal.dart @@ -1,26 +1,32 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:eva_mobile/theme/app_colors.dart'; +import 'package:eva_mobile/features/coin/presentation/providers/coin_provider.dart'; /// Floating profile dropdown modal inspired by modern glass design, -/// styled specifically to match Gambar 2 with Eva AI theme. -class ProfileDropdownModal extends StatelessWidget { +/// styled specifically with Eva AI purple theme and interactive coin balance banner. +class ProfileDropdownModal extends ConsumerWidget { final String userName; final String userEmail; + final int? coinBalance; final VoidCallback onProfileTap; final VoidCallback onEmergencyContactsTap; final VoidCallback onSettingsTap; final VoidCallback onLogoutTap; + final VoidCallback? onCoinTap; const ProfileDropdownModal({ super.key, required this.userName, required this.userEmail, + this.coinBalance, required this.onProfileTap, required this.onEmergencyContactsTap, required this.onSettingsTap, required this.onLogoutTap, + this.onCoinTap, }); String get _userInitials { @@ -33,8 +39,19 @@ class ProfileDropdownModal extends StatelessWidget { return 'SA'; } + void _handleCoinTap(BuildContext context) { + if (onCoinTap != null) { + onCoinTap!(); + } else { + Navigator.of(context, rootNavigator: true).pop(); + Navigator.pushNamed(context, '/coin_purchase'); + } + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final effectiveCoins = coinBalance ?? ref.watch(coinBalanceProvider); + return Container( width: 295, decoration: BoxDecoration( @@ -125,59 +142,115 @@ class ProfileDropdownModal extends StatelessWidget { const SizedBox(height: 14), - // 2. Pro Upgrade Gradient Banner (matching Gambar 2) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF2563EB), Color(0xFF06B6D4)], - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ), - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: const Color(0xFF2563EB).withValues(alpha: 0.25), - blurRadius: 8, - offset: const Offset(0, 3), - ), - ], - ), - child: Row( - children: [ - const Icon( - Icons.workspace_premium_rounded, - color: Color(0xFFFFD54F), - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Upgrade profile', - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: FontWeight.w700, - color: Colors.white, - ), + // 2. Coin Balance Gradient Banner (Purple theme, interactive coin top up) + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _handleCoinTap(context), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF5358CB), // AppColors.primary + Color(0xFF8B93FF), // AppColors.primaryLight + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF5358CB).withValues(alpha: 0.28), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - 'PRO', - style: GoogleFonts.inter( - fontSize: 10, - fontWeight: FontWeight.w900, - color: const Color(0xFF1E293B), - letterSpacing: 0.5, + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.monetization_on_rounded, + color: Color(0xFFFFD54F), + size: 20, + ), ), - ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Saldo Koin', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: 1), + Text( + '$effectiveCoins Koin', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.2, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(999), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.add_circle_outline_rounded, + size: 13, + color: Color(0xFF5358CB), + ), + const SizedBox(width: 4), + Text( + 'Beli', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: const Color(0xFF5358CB), + ), + ), + ], + ), + ), + const SizedBox(width: 6), + Icon( + Icons.arrow_forward_ios_rounded, + size: 11, + color: Colors.white.withValues(alpha: 0.8), + ), + ], ), - ], + ), ), ), @@ -212,7 +285,7 @@ class ProfileDropdownModal extends StatelessWidget { ), const SizedBox(height: 4), - // 6. Menu Item: Keluar Akun (Red accent matching bottom item in Gambar 2) + // 6. Menu Item: Keluar Akun InkWell( onTap: onLogoutTap, borderRadius: BorderRadius.circular(14), diff --git a/apps/eva_mobile/lib/features/monitoring/presentation/screens/monitoring_screen.dart b/apps/eva_mobile/lib/features/monitoring/presentation/screens/monitoring_screen.dart index 23573ed..5b8d35a 100644 --- a/apps/eva_mobile/lib/features/monitoring/presentation/screens/monitoring_screen.dart +++ b/apps/eva_mobile/lib/features/monitoring/presentation/screens/monitoring_screen.dart @@ -13,7 +13,12 @@ import 'package:eva_mobile/widgets/glass_card.dart'; import 'package:eva_mobile/widgets/skeleton_shimmer.dart'; class MonitoringScreen extends ConsumerStatefulWidget { - const MonitoringScreen({super.key}); + final GlobalKey? dassCardKey; + + const MonitoringScreen({ + super.key, + this.dassCardKey, + }); @override ConsumerState createState() => MonitoringScreenState(); @@ -21,6 +26,8 @@ class MonitoringScreen extends ConsumerStatefulWidget { class MonitoringScreenState extends ConsumerState with WidgetsBindingObserver { + late final GlobalKey _defaultDassCardKey = GlobalKey(debugLabel: 'monitoringDassCard'); + GlobalKey get dassCardKey => widget.dassCardKey ?? _defaultDassCardKey; @override void initState() { super.initState(); @@ -285,160 +292,108 @@ class MonitoringScreenState extends ConsumerState ), const SizedBox(height: 16), - // 2. STACKED BAR CHART RITEM SUASANA HATI (7 Emosi) - GlassCard( + // 2. LEMBAR EVALUASI DASS-21 + MonitoringDassCard( + key: dassCardKey, + selectedPeriod: ref.watch(selectedPeriodProvider), + ), + const SizedBox(height: 16), + + // 3. PUSAT EMOSIONAL HARIAN + Container( width: double.infinity, padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: gradientColors.last.withValues(alpha: 0.3), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - 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, - ), - ), - ], - ), + Icon( + Icons.tune_rounded, + size: 20, + color: textColor, ), 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, - ), + Text( + 'PUSAT EMOSIONAL', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: textColor.withValues(alpha: 0.8), + letterSpacing: 0.8, ), ), ], ), - 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: 10), + Text( + emotionalCenter.status, + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w800, + color: textColor, ), ), - 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 + const SizedBox(height: 4), Text( - 'KUNCI WARNA EMOSI', + emotionalCenter.description, style: GoogleFonts.inter( - fontSize: 10, - fontWeight: FontWeight.w700, - color: AppColors.textLight, - letterSpacing: 0.8, + fontSize: 12, + color: textColor.withValues(alpha: 0.9), ), ), - const SizedBox(height: 8), + const SizedBox(height: 16), - 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, + // 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: 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, - ), - ), - ], + child: isSel + ? const Icon(Icons.check, color: Colors.white, size: 16) + : null, ), ); - }).toList(), + }), ), ], ), ), const SizedBox(height: 16), - // 3. TINGKAT RISIKO KESEHATAN MENTAL + // 4. TINGKAT RISIKO KESEHATAN MENTAL GlassCard( width: double.infinity, padding: const EdgeInsets.all(20), @@ -534,194 +489,254 @@ class MonitoringScreenState extends ConsumerState ), const SizedBox(height: 16), - // 4. PUSAT EMOSIONAL HARIAN - Container( + // 5. STACKED BAR CHART RITEM SUASANA HATI (7 Emosi) + GlassCard( 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( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Icon( - Icons.tune_rounded, - size: 20, - color: textColor, + 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), - Text( - 'PUSAT EMOSIONAL', - style: GoogleFonts.inter( - fontSize: 11, - fontWeight: FontWeight.w700, - color: textColor.withValues(alpha: 0.8), - letterSpacing: 0.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: 10), - Text( - emotionalCenter.status, - style: GoogleFonts.inter( - fontSize: 20, - fontWeight: FontWeight.w800, - color: textColor, + 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: 4), + 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( - emotionalCenter.description, + 'KUNCI WARNA EMOSI', style: GoogleFonts.inter( - fontSize: 12, - color: textColor.withValues(alpha: 0.9), + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.textLight, + letterSpacing: 0.8, ), ), - 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) - ]; + const SizedBox(height: 8), - 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, + 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: isSel - ? const Icon(Icons.check, color: Colors.white, size: 16) - : null, + ), + 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(), ), ], ), ), - // 5. KARTU RINGKASAN & AKSES LEMBAR DASS-21 (Hanya muncul jika ada asesmen DASS riil) - MonitoringDassCard(selectedPeriod: ref.watch(selectedPeriodProvider)), ], ); } Widget _buildLoadingState() { - return SkeletonShimmerHost( - child: Column( - children: [ - // 1. Clinical Status Header Skeleton - GlassCard( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Ringkasan AI Skeleton + SkeletonShimmerHost( + child: GlassCard( width: double.infinity, - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(18), 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 SkeletonCircle(size: 32), + const SizedBox(width: 10), + const SkeletonLine(width: 120, height: 14), ], ), - const SizedBox(height: 16), + const SizedBox(height: 14), const SkeletonLine(width: double.infinity, height: 13), - const SizedBox(height: 6), + const SizedBox(height: 8), const SkeletonLine(width: 200, height: 13), ], ), ), - const SizedBox(height: 18), + ), + const SizedBox(height: 16), - // 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), + // 2. Lembar Evaluasi DASS-21 (mounted immediately with key) + MonitoringDassCard( + key: dassCardKey, + selectedPeriod: ref.watch(selectedPeriodProvider), + ), + const SizedBox(height: 16), - // 3. Dual Metrics Skeleton - Row( + // 3. Trend Chart & Metrics Skeleton + SkeletonShimmerHost( + child: Column( 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), - ], - ), + 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(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), - ], + const SizedBox(height: 18), + Row( + children: [ + Expanded( + child: GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + children: const [ + SkeletonLine(width: 60, height: 12), + SizedBox(height: 8), + SkeletonLine(width: 40, height: 18), + ], + ), + ), ), - ), + const SizedBox(width: 14), + Expanded( + child: GlassCard( + padding: const EdgeInsets.all(16), + child: Column( + children: const [ + SkeletonLine(width: 60, height: 12), + SizedBox(height: 8), + SkeletonLine(width: 40, height: 18), + ], + ), + ), + ), + ], ), ], ), - ], - ), + ), + ], ); } diff --git a/apps/eva_mobile/lib/features/monitoring/presentation/widgets/monitoring_dass_card.dart b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/monitoring_dass_card.dart index c392e4b..b68e8d2 100644 --- a/apps/eva_mobile/lib/features/monitoring/presentation/widgets/monitoring_dass_card.dart +++ b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/monitoring_dass_card.dart @@ -7,6 +7,8 @@ import "package:eva_mobile/theme/app_colors.dart"; import "package:eva_mobile/widgets/app_snack_bar.dart"; import "package:eva_mobile/widgets/glass_card.dart"; import "../../../dass/presentation/providers/dass_provider.dart"; +import "../../../tutorial/domain/entities/tutorial_step.dart"; +import "../../../tutorial/presentation/providers/tutorial_provider.dart"; class MonitoringDassCard extends ConsumerWidget { final String selectedPeriod; @@ -98,10 +100,8 @@ class MonitoringDassCard extends ConsumerWidget { required int? depressionScore, required String depressionSeverity, }) { - return Padding( - padding: const EdgeInsets.only(top: 16.0), - child: GlassCard( - width: double.infinity, + return GlassCard( + width: double.infinity, padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -211,6 +211,13 @@ class MonitoringDassCard extends ConsumerWidget { height: 44, child: ElevatedButton.icon( onPressed: () async { + final tutorialState = ref.read(tutorialNotifierProvider); + if (tutorialState.isActive && + tutorialState.currentStep == TutorialStep.analytics) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.analytics); + } await Navigator.pushNamed( context, '/dass_assessment', @@ -247,8 +254,7 @@ class MonitoringDassCard extends ConsumerWidget { ), ], ), - ), - ); + ); } String _getIndonesianSeverity(String scaleType, int score, String rawSeverity) { diff --git a/apps/eva_mobile/lib/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart index 8632452..8869fcc 100644 --- a/apps/eva_mobile/lib/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart +++ b/apps/eva_mobile/lib/features/monitoring/presentation/widgets/stacked_emotion_chart_painter.dart @@ -37,11 +37,24 @@ class StackedEmotionChartPainter extends CustomPainter { continue; } + // Top position of the bar (leaving 4dp headroom) + const double barTop = 4.0; + final totalBarRect = Rect.fromLTRB(barLeft, barTop, barLeft + barWidth, size.height); + final double cornerRadius = min(6.0, barWidth / 2); + final totalBarRRect = RRect.fromRectAndRadius( + totalBarRect, + Radius.circular(cornerRadius), + ); + + // Clip canvas to smoothly round all 4 outer corners of the bar uniformly + canvas.save(); + canvas.clipRRect(totalBarRRect); + for (int j = 0; j < segments.length; j++) { final double ratio = segments[j]; if (ratio <= 0) continue; - final double segHeight = (size.height - 4) * (ratio / totalRatio); + final double segHeight = (size.height - barTop) * (ratio / totalRatio); final double top = currentBottom - segHeight; final rect = Rect.fromLTRB(barLeft, top, barLeft + barWidth, currentBottom); @@ -49,17 +62,11 @@ class StackedEmotionChartPainter extends CustomPainter { ..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); + canvas.drawRect(rect, paint); currentBottom = top; } + + canvas.restore(); } } diff --git a/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart b/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart index 170defb..6d34efa 100644 --- a/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart +++ b/apps/eva_mobile/lib/features/tutorial/domain/entities/tutorial_step.dart @@ -1,7 +1,12 @@ enum TutorialStep { voiceSession, + voiceCoins, + voiceStart, + voiceCompleted, journal, analytics, + dassDetail, + dassItem, care, } @@ -24,6 +29,7 @@ class TutorialContent { final SpotlightShape shape; final PointerDirection pointerDirection; final bool isInteractiveTarget; + final String nextButtonLabel; const TutorialContent({ required this.title, @@ -33,6 +39,7 @@ class TutorialContent { this.shape = SpotlightShape.circle, this.pointerDirection = PointerDirection.down, this.isInteractiveTarget = true, + this.nextButtonLabel = 'Lanjut', }); static const Map stepContents = { @@ -46,6 +53,36 @@ class TutorialContent { pointerDirection: PointerDirection.down, isInteractiveTarget: true, ), + TutorialStep.voiceCoins: TutorialContent( + title: 'Telepon Pakai Koin', + description: + 'Setiap sesi suara bersama Eva menggunakan koin. Pantau saldo atau isi ulang koinmu kapan saja di sini.', + actionHint: 'Ketuk koin atau tombol Lanjut', + badgeText: 'INFO KOIN', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.up, + isInteractiveTarget: false, + ), + TutorialStep.voiceStart: TutorialContent( + title: 'Mulai Sesi Suara', + description: + 'Tekan tombol Mulai Sesi Suara untuk memulai percakapan langsung dan mencurahkan perasaanmu kepada Eva.', + actionHint: 'Ketuk Mulai Sesi Suara', + badgeText: 'SESI SUARA', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: true, + ), + TutorialStep.voiceCompleted: TutorialContent( + title: 'Refleksi Sesi Suara', + description: + 'Sesi suaramu telah tersimpan. Buka jurnal refleksi untuk melihat intisari emosi dan renungan harianmu.', + actionHint: 'Ketuk Lihat Detail Jurnal Refleksi', + badgeText: 'REFLEKSI AI', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: true, + ), TutorialStep.journal: TutorialContent( title: 'Jurnal Refleksi Otomatis', description: @@ -57,24 +94,45 @@ class TutorialContent { isInteractiveTarget: false, ), TutorialStep.analytics: TutorialContent( - title: 'Tren Emosi & DASS-21', + title: 'Lembar Evaluasi DASS-21', description: - 'AI merangkum indikator suasana hatimu otomatis. Kamu memiliki kendali penuh untuk menyesuaikan nilainya secara mandiri.', - actionHint: 'Pantau kesehatan emosimu berkala', + 'AI merangkum indikator Stres, Cemas, dan Depresi secara otomatis. Ketuk untuk meninjau asesmenmu hari ini.', + actionHint: 'Ketuk Tinjau Asesmen Hari Ini atau Lanjut', badgeText: 'ANALITIK DASS', shape: SpotlightShape.roundedRectangle, pointerDirection: PointerDirection.down, isInteractiveTarget: false, ), + TutorialStep.dassDetail: TutorialContent( + title: 'Tinjauan Indikator DASS-21', + description: + 'Di sini kamu dapat melihat skor klinis dan evaluasi suasana hatimu. Kamu memiliki kendali penuh untuk meninjau secara mandiri.', + actionHint: 'Ketuk Lanjut untuk melihat butir evaluasi', + badgeText: 'PENJELASAN DASS', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: false, + ), + TutorialStep.dassItem: TutorialContent( + title: 'Hasil Ekstraksi Otomatis AI', + description: + 'EVA otomatis mendeteksi butir ini dari percakapanmu. Jika skornya dirasa kurang pas, kamu bebas menggeser atau mengubahnya secara manual.', + actionHint: 'Ketuk Lanjut untuk melihat layanan bantuan', + badgeText: 'EKSTRAKSI AI', + shape: SpotlightShape.roundedRectangle, + pointerDirection: PointerDirection.down, + isInteractiveTarget: false, + ), TutorialStep.care: TutorialContent( title: 'Layanan & Dukungan Sebaya', description: 'Jika butuh teman bicara manusia, temukan konseling profesional dan volunteer terpercaya di sini.', - actionHint: 'Bantuan selalu tersedia untukmu', + actionHint: 'Bantuan selalu tersedia di menu Layanan', badgeText: 'LAYANAN CARE', shape: SpotlightShape.roundedRectangle, pointerDirection: PointerDirection.down, isInteractiveTarget: false, + nextButtonLabel: 'Selesai', ), }; } diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/providers/tutorial_provider.dart b/apps/eva_mobile/lib/features/tutorial/presentation/providers/tutorial_provider.dart index fb5979e..b6d05c8 100644 --- a/apps/eva_mobile/lib/features/tutorial/presentation/providers/tutorial_provider.dart +++ b/apps/eva_mobile/lib/features/tutorial/presentation/providers/tutorial_provider.dart @@ -89,11 +89,51 @@ class TutorialNotifier extends StateNotifier { Future onTargetTapped(TutorialStep step) async { if (state.currentStep == step) { if (step == TutorialStep.voiceSession) { - // Voice session launched; pause overlay so it does not interfere with voice screen + // Center button tapped -> navigate to /chat and highlight coin balance + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.voiceCoins, + ); + } else if (step == TutorialStep.voiceCoins) { + // Coin explanation acknowledged -> highlight Mulai Sesi Suara button + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.voiceStart, + ); + } else if (step == TutorialStep.voiceStart) { + // Voice call initiated -> pause spotlight overlay during active call state = state.copyWith( isActive: false, + currentStep: TutorialStep.voiceCompleted, + ); + } else if (step == TutorialStep.voiceCompleted) { + // User tapped Lihat Detail Jurnal Refleksi -> proceed to journal tab + state = state.copyWith( + isActive: true, currentStep: TutorialStep.journal, ); + } else if (step == TutorialStep.journal) { + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.analytics, + ); + } else if (step == TutorialStep.analytics) { + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.dassDetail, + ); + } else if (step == TutorialStep.dassDetail) { + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.dassItem, + ); + } else if (step == TutorialStep.dassItem) { + state = state.copyWith( + isActive: true, + currentStep: TutorialStep.care, + ); + } else if (step == TutorialStep.care) { + completeTutorial(); } else { nextStep(); } diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart index f3d1bb8..281f71d 100644 --- a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart +++ b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/branded_guidance_card.dart @@ -14,6 +14,7 @@ class BrandedGuidanceCard extends StatelessWidget { final PointerDirection pointerDirection; final double pointerHorizontalOffset; final bool isInteractiveTarget; + final String nextButtonLabel; final VoidCallback onSkip; final VoidCallback? onNext; @@ -26,6 +27,7 @@ class BrandedGuidanceCard extends StatelessWidget { this.pointerDirection = PointerDirection.down, this.pointerHorizontalOffset = 0.0, this.isInteractiveTarget = true, + this.nextButtonLabel = 'Lanjut', required this.onSkip, this.onNext, }); @@ -204,7 +206,7 @@ class BrandedGuidanceCard extends StatelessWidget { ), ), child: Text( - 'Lanjut', + nextButtonLabel, style: GoogleFonts.inter( fontSize: 12.5, fontWeight: FontWeight.w600, diff --git a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart index 5eb4102..998c0d2 100644 --- a/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart +++ b/apps/eva_mobile/lib/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import '../../../../theme/app_colors.dart'; @@ -15,6 +16,8 @@ import 'branded_guidance_card.dart'; class InteractiveSpotlightOverlay extends StatefulWidget { final GlobalKey? targetKey; final Rect? targetRect; + final GlobalKey? secondaryTargetKey; + final Rect? secondaryTargetRect; final TutorialContent content; final VoidCallback onSkip; final VoidCallback? onTargetTap; @@ -25,6 +28,8 @@ class InteractiveSpotlightOverlay extends StatefulWidget { super.key, this.targetKey, this.targetRect, + this.secondaryTargetKey, + this.secondaryTargetRect, required this.content, required this.onSkip, this.onTargetTap, @@ -42,6 +47,8 @@ class _InteractiveSpotlightOverlayState extends State measure()); + } + }); + + int attempts = 0; + _pollingTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) { + attempts++; + if (!mounted) { + timer.cancel(); + return; + } + _updateTargetMeasurement(); + // Keep tracking for up to 20 frames (1s) to accommodate page scroll and layout changes + if (attempts >= 20) { + timer.cancel(); } }); } @@ -90,30 +114,26 @@ class _InteractiveSpotlightOverlayState extends State= 0 && _measuredTargetRect != rect) { - setState(() { - _measuredTargetRect = rect; - }); + final rect = offset & size; + + // Validate that target widget has rendered dimensions and is within screen boundaries + if (rect.right <= 0 || + rect.left >= screenSize.width || + rect.bottom <= 0 || + rect.top >= screenSize.height) { + return null; } + + return rect; + } + return null; + } + + void _updateTargetMeasurement() { + if (!mounted) return; + + final primary = widget.targetRect ?? _measureKey(widget.targetKey); + final secondary = + widget.secondaryTargetRect ?? _measureKey(widget.secondaryTargetKey); + + if (_measuredTargetRect != primary || + _measuredSecondaryTargetRect != secondary) { + setState(() { + _measuredTargetRect = primary; + _measuredSecondaryTargetRect = secondary; + }); } } @override void dispose() { + _pollingTimer?.cancel(); _fadeController.dispose(); _haloController.dispose(); super.dispose(); @@ -161,6 +206,9 @@ class _InteractiveSpotlightOverlayState extends State= spaceBelow || spaceAbove >= 220; + // Prefer placing card below target; only place above if not enough space below AND more space above + final bool placeAbove = (spaceBelow < 280 && spaceAbove > spaceBelow); // Horizontal placement: Center card relative to target, clamped to screen safe bounds final double targetCenterX = targetRect.center.dx; double cardLeft = targetCenterX - (cardWidth / 2); - final double minLeft = safePadding.left + 16.0; final double maxLeft = screenSize.width - safePadding.right - 16.0 - cardWidth; cardLeft = cardLeft.clamp(minLeft, math.max(minLeft, maxLeft)); @@ -254,8 +339,8 @@ class _InteractiveSpotlightOverlayState extends State }) { DailyProgressLocalService.recordConversationCheckin(hasConversation: true); + final tutorialState = ref.read(tutorialNotifierProvider); + final isTutorialActive = !tutorialState.isCompleted && + (tutorialState.currentStep == TutorialStep.voiceCompleted || + tutorialState.currentStep == TutorialStep.voiceStart || + tutorialState.currentStep == TutorialStep.voiceCoins); + + if (isTutorialActive) { + ref + .read(tutorialNotifierProvider.notifier) + .resumeAtStep(TutorialStep.voiceCompleted); + } + + final GlobalKey journalButtonKey = GlobalKey(debugLabel: 'journalDetailButton'); + + void navigateToJournal(BuildContext bottomSheetContext) { + final currentTutorialState = ref.read(tutorialNotifierProvider); + if (!currentTutorialState.isCompleted && + (currentTutorialState.currentStep == TutorialStep.voiceCompleted || + currentTutorialState.currentStep == TutorialStep.voiceStart || + currentTutorialState.currentStep == TutorialStep.voiceCoins)) { + ref + .read(tutorialNotifierProvider.notifier) + .onTargetTapped(TutorialStep.voiceCompleted); + } + Navigator.pop(bottomSheetContext); // Close Bottom Sheet + Navigator.pushNamedAndRemoveUntil( + context, + '/home', + (route) => false, + arguments: {'initialIndex': 1}, + ); + } + showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (context) { - return Container( + isDismissible: !isTutorialActive, + enableDrag: !isTutorialActive, + builder: (bottomSheetContext) { + final Widget sheetCard = Container( padding: const EdgeInsets.all(24), decoration: const BoxDecoration( color: Colors.white, @@ -516,35 +554,32 @@ class _VoiceCallScreenState extends ConsumerState const SizedBox(height: 20), ], CustomPillButton( + key: journalButtonKey, text: 'Lihat Detail Jurnal Refleksi', isOutline: hasDassTriggered, - onPressed: () { - Navigator.pop(context); // Close Bottom Sheet - Navigator.pushNamedAndRemoveUntil( - context, - '/home', - (route) => false, - arguments: {'initialIndex': 1}, - ); - }, + onPressed: () => navigateToJournal(bottomSheetContext), ), const SizedBox(height: 6), Center( child: TextButton( - onPressed: () { - Navigator.pop(context); - Navigator.pushNamedAndRemoveUntil( - context, - '/home', - (route) => false, - ); - }, + onPressed: isTutorialActive + ? null + : () { + Navigator.pop(bottomSheetContext); + Navigator.pushNamedAndRemoveUntil( + context, + '/home', + (route) => false, + ); + }, child: Text( 'Kembali ke Beranda', style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w600, - color: AppColors.textSecondary, + color: isTutorialActive + ? AppColors.textLight.withValues(alpha: 0.35) + : AppColors.textSecondary, ), ), ), @@ -552,6 +587,34 @@ class _VoiceCallScreenState extends ConsumerState ], ), ); + + if (isTutorialActive) { + return SizedBox( + height: MediaQuery.of(bottomSheetContext).size.height, + child: Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 0, + child: sheetCard, + ), + InteractiveSpotlightOverlay( + targetKey: journalButtonKey, + content: TutorialContent.stepContents[TutorialStep.voiceCompleted]!, + onSkip: () { + ref.read(tutorialNotifierProvider.notifier).skipTutorial(); + Navigator.pop(bottomSheetContext); + }, + onNext: () => navigateToJournal(bottomSheetContext), + onTargetTap: () => navigateToJournal(bottomSheetContext), + ), + ], + ), + ); + } + + return sheetCard; }, ); } diff --git a/apps/eva_mobile/lib/main.dart b/apps/eva_mobile/lib/main.dart index e74ee03..237ab12 100644 --- a/apps/eva_mobile/lib/main.dart +++ b/apps/eva_mobile/lib/main.dart @@ -25,7 +25,7 @@ class EvaApp extends StatelessWidget { title: 'EVA AI - Mental Health Companion', debugShowCheckedModeBanner: false, theme: AppTheme.lightTheme, - initialRoute: '/', + initialRoute: '/home', onGenerateRoute: AppRouter.onGenerateRoute, builder: (context, child) { return WebPhoneFrameWrapper( diff --git a/apps/eva_mobile/lib/theme/app_colors.dart b/apps/eva_mobile/lib/theme/app_colors.dart index b81e6ba..05cc4d8 100644 --- a/apps/eva_mobile/lib/theme/app_colors.dart +++ b/apps/eva_mobile/lib/theme/app_colors.dart @@ -40,7 +40,9 @@ class AppColors { static const Color divider = Color(0x0E5358CB); // Soft gentle divider (5.5% periwinkle) - static const Color success = Color(0xFF4CAF50); + static const Color success = Color(0xFF4CAF50); // Soft Material Green + static const Color successContainer = Color(0xFFE8F5E9); // Soft Green Container + static const Color successDark = Color(0xFF2E7D32); // Dark Green for text/icons on light container static const Color warning = Color(0xFFFF9800); diff --git a/apps/eva_mobile/lib/theme/app_theme.dart b/apps/eva_mobile/lib/theme/app_theme.dart index 836b8f6..41f6415 100644 --- a/apps/eva_mobile/lib/theme/app_theme.dart +++ b/apps/eva_mobile/lib/theme/app_theme.dart @@ -16,17 +16,18 @@ class AppTheme { scaffoldBackgroundColor: AppColors.background, colorScheme: const ColorScheme.light( - primary: AppColors.primary, - + onPrimary: Colors.white, primaryContainer: AppColors.primaryContainer, - secondary: AppColors.secondary, - + onSecondary: Colors.white, tertiary: AppColors.tertiary, - surface: AppColors.surface, - + onSurface: AppColors.textPrimary, + ), + chipTheme: const ChipThemeData( + checkmarkColor: Colors.white, + selectedColor: AppColors.primary, ), appBarTheme: const AppBarTheme( diff --git a/apps/eva_mobile/lib/widgets/app_snack_bar.dart b/apps/eva_mobile/lib/widgets/app_snack_bar.dart index 17bc99b..11edafa 100644 --- a/apps/eva_mobile/lib/widgets/app_snack_bar.dart +++ b/apps/eva_mobile/lib/widgets/app_snack_bar.dart @@ -21,7 +21,7 @@ class AppSnackBar { case SnackBarVariant.primary: return AppColors.primary; case SnackBarVariant.success: - return const Color(0xFF10B981); // Emerald + return AppColors.success; case SnackBarVariant.warning: return const Color(0xFFF59E0B); // Amber case SnackBarVariant.danger: diff --git a/apps/eva_mobile/lib/widgets/custom_button.dart b/apps/eva_mobile/lib/widgets/custom_button.dart index b866886..fb80b04 100644 --- a/apps/eva_mobile/lib/widgets/custom_button.dart +++ b/apps/eva_mobile/lib/widgets/custom_button.dart @@ -1,10 +1,100 @@ import 'package:flutter/material.dart'; - import 'package:google_fonts/google_fonts.dart'; import '../theme/app_colors.dart'; +/// Primary custom pill button with optional icon, loading state, and outline style. +class CustomButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool isOutline; + final bool isLoading; + final IconData? suffixIcon; + final IconData? prefixIcon; + final double height; + final double? width; + final Color? backgroundColor; + final Color? textColor; + const CustomButton({ + super.key, + required this.text, + this.onPressed, + this.isOutline = false, + this.isLoading = false, + this.suffixIcon, + this.prefixIcon, + this.height = 54.0, + this.width, + this.backgroundColor, + this.textColor, + }); + + @override + Widget build(BuildContext context) { + final effectiveBgColor = backgroundColor ?? (isOutline ? Colors.transparent : AppColors.primary); + final effectiveFgColor = textColor ?? (isOutline ? AppColors.primary : Colors.white); + + return SizedBox( + width: width ?? double.infinity, + height: height, + child: ElevatedButton( + onPressed: isLoading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: effectiveBgColor, + foregroundColor: effectiveFgColor, + disabledBackgroundColor: isOutline ? Colors.transparent : AppColors.primary.withValues(alpha: 0.6), + disabledForegroundColor: Colors.white.withValues(alpha: 0.8), + elevation: isOutline ? 0 : 4, + shadowColor: AppColors.primary.withValues(alpha: 0.3), + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + side: isOutline + ? BorderSide(color: backgroundColor ?? AppColors.primary, width: 1.5) + : BorderSide.none, + ), + ), + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (prefixIcon != null) ...[ + Icon(prefixIcon, size: 20, color: effectiveFgColor), + const SizedBox(width: 8), + ], + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: effectiveFgColor, + ), + ), + ), + if (suffixIcon != null) ...[ + const SizedBox(width: 8), + Icon(suffixIcon, size: 20, color: effectiveFgColor), + ], + ], + ), + ), + ); + } +} class CustomPillButton extends StatelessWidget { diff --git a/apps/eva_mobile/test/core/app_router_test.dart b/apps/eva_mobile/test/core/app_router_test.dart index eadb21d..6648bc4 100644 --- a/apps/eva_mobile/test/core/app_router_test.dart +++ b/apps/eva_mobile/test/core/app_router_test.dart @@ -53,6 +53,21 @@ void main() { expect(route!.settings.name, '/diary_detail'); }); + test('Returns PageRouteBuilder for /coin_purchase and /coins', () { + final route1 = AppRouter.onGenerateRoute( + const RouteSettings(name: '/coin_purchase'), + ); + expect(route1, isNotNull); + expect(route1, isA()); + expect(route1!.settings.name, '/coin_purchase'); + + final route2 = AppRouter.onGenerateRoute( + const RouteSettings(name: '/coins'), + ); + expect(route2, isNotNull); + expect(route2, isA()); + }); + test('Returns null for unknown route to let MaterialApp handle it or 404', () { final route = AppRouter.onGenerateRoute( const RouteSettings(name: '/unknown_non_existent_route'), diff --git a/apps/eva_mobile/test/features/care/care_provider_test.dart b/apps/eva_mobile/test/features/care/care_provider_test.dart index 9336bd7..12d0c33 100644 --- a/apps/eva_mobile/test/features/care/care_provider_test.dart +++ b/apps/eva_mobile/test/features/care/care_provider_test.dart @@ -1,9 +1,20 @@ +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/core/network/api_client.dart'; import 'package:eva_mobile/features/care/data/models/psychologist_model.dart'; import 'package:eva_mobile/features/care/data/models/clinic_model.dart'; import 'package:eva_mobile/features/care/data/models/community_status_model.dart'; import 'package:eva_mobile/features/care/data/datasources/mock_care_datasource.dart'; +import 'package:eva_mobile/features/care/data/repositories/care_repository_impl.dart'; +import 'package:eva_mobile/features/care/data/repositories/fallback_care_repository.dart'; +import 'package:eva_mobile/features/care/domain/entities/clinic_entity.dart'; +import 'package:eva_mobile/features/care/domain/entities/community_status_entity.dart'; import 'package:eva_mobile/features/care/domain/entities/consultation_session_entity.dart'; +import 'package:eva_mobile/features/care/domain/entities/psychologist_entity.dart'; +import 'package:eva_mobile/features/care/domain/repositories/care_repository.dart'; +import 'package:eva_mobile/features/care/presentation/providers/care_provider.dart'; void main() { group('Care Domain & Model Unit Tests', () { @@ -18,11 +29,35 @@ void main() { 'review_count': 25, 'experience_years': 4, 'specialties': ['Kecemasan', 'Stres'], - 'fee_per_session': 150000, + 'coins_per_session': 50, 'duration_minutes': 50, 'available_online': true, 'clinic_affiliation': 'Klinik Sehat', 'bio': 'Test bio description', + 'review_tags': [ + {'label': 'Sangat Sabar & Empati', 'sentiment': 'positive'}, + {'label': 'Slot Sesi Cepat Penuh', 'sentiment': 'negative'}, + ], + 'testimonials': [ + { + 'id': 'rev_test_1', + 'patient_name': 'Pasien Terverifikasi (A.B.)', + 'sentiment': 'positive', + 'label': 'Sangat Sabar & Empati', + 'description': 'Konselingnya sangat membantu meredakan overthinking.', + 'rating': 5.0, + 'time_ago': '2 hari lalu', + }, + ], + 'patient_stats': { + 'total_patients_treated': 320, + 'total_sessions_completed': 580, + 'satisfaction_rate': 98.7, + 'positive_review_rate': 96.0, + 'recovery_rate': 94.0, + 'avg_recovery_sessions': 3.7, + 'recovery_description': '94% pasien merasakan perbaikan kecemasan signifikan', + }, }; final model = PsychologistModel.fromJson(json); @@ -30,11 +65,22 @@ void main() { expect(model.name, 'Test Psychologist, M.Psi.'); expect(model.isPsychiatrist, isFalse); expect(model.specialties.length, 2); - expect(model.feePerSession, 150000); + expect(model.coinsPerSession, 50); + expect(model.feePerSession, 50); + expect(model.effectiveReviewTags.length, 2); + expect(model.effectiveReviewTags[0].isPositive, isTrue); + expect(model.effectiveReviewTags[1].isNegative, isTrue); + expect(model.effectiveTestimonials.length, 1); + expect(model.effectiveTestimonials[0].label, 'Sangat Sabar & Empati'); + expect(model.patientStats.totalPatientsTreated, 320); + expect(model.patientStats.positiveReviewRate, 96.0); + expect(model.patientStats.negativeReviewRate, 4.0); final outJson = model.toJson(); expect(outJson['registration_number'], 'SIPP: 1234'); - expect(outJson['fee_per_session'], 150000); + expect(outJson['coins_per_session'], 50); + expect(outJson['fee_per_session'], 50); + expect(outJson['patient_stats']['total_patients_treated'], 320); }); test('Psychiatrist detection via title', () { @@ -48,11 +94,12 @@ void main() { reviewCount: 10, experienceYears: 8, specialties: ['Bipolar'], - feePerSession: 250000, + coinsPerSession: 60, bio: 'Bio psikiater', ); expect(psikiater.isPsychiatrist, isTrue); + expect(psikiater.coinsPerSession, 60); }); test('ClinicModel correctly deserializes from JSON', () { @@ -91,6 +138,74 @@ void main() { expect(upgraded.isVipActive, isTrue); expect(upgraded.vipRemainingDays, 30); }); + + test('Psychologist and Volunteer badges combination logic', () { + // 1. Volunteer without certificate (free) + const volunteer = PsychologistModel( + id: 'vol_1', + name: 'Bayu Aditya', + title: 'Relawan Sebaya', + registrationNumber: 'Cert PFA', + avatarUrl: '', + rating: 4.8, + reviewCount: 10, + experienceYears: 1, + specialties: ['Curhat'], + coinsPerSession: 0, + bio: 'Bio', + practitionerType: PractitionerType.volunteer, + hasPsychologistCertificate: false, + isVolunteerRole: true, + ); + expect(volunteer.isVolunteer, isTrue); + expect(volunteer.hasPsychologistCertificate, isFalse); + expect(volunteer.hasBothBadges, isFalse); + expect(volunteer.badgeHeaderTitle, 'Relawan'); + + // 2. Psychologist with certificate only + const pro = PsychologistModel( + id: 'pro_1', + name: 'Dimas Wicaksono', + title: 'Psikolog Klinis', + registrationNumber: 'SIPP: 1234', + avatarUrl: '', + rating: 4.9, + reviewCount: 20, + experienceYears: 5, + specialties: ['Karir'], + coinsPerSession: 60, + bio: 'Bio', + practitionerType: PractitionerType.professional, + hasPsychologistCertificate: true, + isVolunteerRole: false, + ); + expect(pro.isVolunteer, isFalse); + expect(pro.hasPsychologistCertificate, isTrue); + expect(pro.hasBothBadges, isFalse); + expect(pro.badgeHeaderTitle, 'Profesional'); + + // 3. User with BOTH badges: Psychologist AND Volunteer + const both = PsychologistModel( + id: 'both_1', + name: 'Amanda Zahra', + title: 'Psikolog & Relawan', + registrationNumber: 'SIPP: 5678', + avatarUrl: '', + rating: 5.0, + reviewCount: 50, + experienceYears: 6, + specialties: ['Kecemasan'], + coinsPerSession: 50, + bio: 'Bio', + practitionerType: PractitionerType.professional, + hasPsychologistCertificate: true, + isVolunteerRole: true, + ); + expect(both.isVolunteer, isTrue); + expect(both.hasPsychologistCertificate, isTrue); + expect(both.hasBothBadges, isTrue); + expect(both.badgeHeaderTitle, 'Relawan & Profesional'); + }); }); group('MockCareDataSource Functional Tests', () { @@ -106,11 +221,17 @@ void main() { expect(list.length, greaterThanOrEqualTo(3)); }); - test('Filters psychologists by psychiatrist category', () async { - final psychiatrists = await dataSource.getPsychologists(category: 'psychiatrist'); - expect(psychiatrists.isNotEmpty, isTrue); - for (final p in psychiatrists) { - expect(p.isPsychiatrist, isTrue); + test('Filters psychologists by practitioner category', () async { + final professionals = await dataSource.getPsychologists(category: 'professional'); + expect(professionals.isNotEmpty, isTrue); + for (final p in professionals) { + expect(p.isProfessional, isTrue); + } + + final volunteers = await dataSource.getPsychologists(category: 'volunteer'); + expect(volunteers.isNotEmpty, isTrue); + for (final p in volunteers) { + expect(p.isVolunteer, isTrue); } }); @@ -162,4 +283,137 @@ void main() { expect(updatedSessions.first.id, newSession.id); }); }); + + group('FallbackCareRepository & Mode API vs Fallback Dummy Tests', () { + late FakeRemoteCareRepository fakeRemote; + late MockCareDataSource mockDataSource; + late CareRepository mockRepo; + late FallbackCareRepository fallbackRepo; + + setUp(() { + fakeRemote = FakeRemoteCareRepository(); + mockDataSource = MockCareDataSource(); + mockRepo = CareRepositoryImpl(mockDataSource); + fallbackRepo = FallbackCareRepository(remote: fakeRemote, mock: mockRepo); + }); + + test('Mode API: Returns remote data when remote API endpoint succeeds', () async { + fakeRemote.shouldThrow404 = false; + final results = await fallbackRepo.getPsychologists(); + expect(results.length, 1); + expect(results.first.id, 'remote_psy_1'); + expect(results.first.name, 'Remote Psychologist, M.Psi.'); + expect(results.first.coinsPerSession, 45); + }); + + test('Fallback Dummy: Transparently falls back to mock data on 404 (Endpoint not yet implemented)', () async { + fakeRemote.shouldThrow404 = true; + final results = await fallbackRepo.getPsychologists(); + expect(results.isNotEmpty, isTrue); + expect(results.any((p) => p.name.contains('Amanda')), isTrue); + }); + + test('Fallback Dummy: Transparently falls back to mock data on network error / connection refused (Offline)', () async { + fakeRemote.shouldThrowSocket = true; + final results = await fallbackRepo.getPsychologists(); + expect(results.isNotEmpty, isTrue); + expect(results.any((p) => p.name.contains('Amanda')), isTrue); + }); + + test('Mode API: Propagates non-fallback server errors (HTTP 500) without masking backend bugs', () async { + fakeRemote.shouldThrow500 = true; + expect( + () async => fallbackRepo.getPsychologists(), + throwsA(isA().having((e) => e.statusCode, 'statusCode', 500)), + ); + }); + + test('Fallback Dummy: Falls back to mock data for clinics on 404', () async { + fakeRemote.shouldThrow404 = true; + final clinics = await fallbackRepo.getClinics(); + expect(clinics.isNotEmpty, isTrue); + }); + + test('Fallback Dummy: Falls back to mock data for community status on 404', () async { + fakeRemote.shouldThrow404 = true; + final status = await fallbackRepo.getCommunityStatus(); + expect(status.isMember, isFalse); + }); + }); + + group('careRepositoryProvider DI Switching Tests', () { + test('Resolves to CareRepositoryImpl when useMockData is true', () { + AppConfig.useMockData = true; + final container = ProviderContainer(); + addTearDown(container.dispose); + + final repo = container.read(careRepositoryProvider); + expect(repo, isA()); + }); + }); +} + +class FakeRemoteCareRepository implements CareRepository { + bool shouldThrow404 = false; + bool shouldThrow500 = false; + bool shouldThrowSocket = false; + + final List remotePsychologists = [ + const PsychologistModel( + id: 'remote_psy_1', + name: 'Remote Psychologist, M.Psi.', + title: 'Psikolog Klinis Remote', + registrationNumber: 'SIPP: 9999', + avatarUrl: '', + rating: 5.0, + reviewCount: 1, + experienceYears: 1, + specialties: ['Remote Test'], + bio: 'Remote bio test', + coinsPerSession: 45, + ), + ]; + + @override + Future> getPsychologists({String? category, String? query}) async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + if (shouldThrow500) throw ApiException('Internal Error', statusCode: 500); + if (shouldThrowSocket) throw const SocketException('Connection refused'); + return remotePsychologists; + } + + @override + Future> getClinics({String? query}) async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + return []; + } + + @override + Future getCommunityStatus() async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + return const CommunityStatusModel(isMember: true, isVipActive: false); + } + + @override + Future joinCommunityWithVipTrial() async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + return const CommunityStatusModel(isMember: true, isVipActive: true, vipRemainingDays: 30); + } + + @override + Future> getConsultationSessions() async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + return []; + } + + @override + Future bookConsultation({ + required String psychologistId, + required DateTime preferredDateTime, + required ConsultationType sessionType, + String? complaints, + }) async { + if (shouldThrow404) throw ApiException('Not Found', statusCode: 404); + throw UnimplementedError(); + } } diff --git a/apps/eva_mobile/test/features/care/care_screen_test.dart b/apps/eva_mobile/test/features/care/care_screen_test.dart index 4b58cc4..caf0147 100644 --- a/apps/eva_mobile/test/features/care/care_screen_test.dart +++ b/apps/eva_mobile/test/features/care/care_screen_test.dart @@ -3,11 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/core/routes/app_router.dart'; import 'package:eva_mobile/features/care/presentation/screens/care_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_schedule_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_history_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/psychologist_video_call_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/psychologist_detail_screen.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/care_quick_actions.dart'; import 'package:eva_mobile/features/care/presentation/widgets/community_banner_card.dart'; -import 'package:eva_mobile/features/care/presentation/widgets/community_paywall_sheet.dart'; import 'package:eva_mobile/features/care/presentation/widgets/psychologist_card.dart'; -import 'package:eva_mobile/features/care/presentation/widgets/clinic_card.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/psychologist_certificate_carousel.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/clinic_location_map_card.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/consultation_result_bottom_sheet.dart'; +import 'package:eva_mobile/features/care/presentation/widgets/psychologist_role_badge.dart'; void main() { setUp(() { @@ -15,18 +23,20 @@ void main() { SharedPreferences.setMockInitialValues({ 'user_name': 'Alexander Agung', 'user_email': 'alex@example.com', + 'user_coin_balance': 120, }); }); Widget createWidgetUnderTest() { return const ProviderScope( child: MaterialApp( + onGenerateRoute: AppRouter.onGenerateRoute, home: CareScreen(), ), ); } - testWidgets('CareScreen renders header, banner, and filter chips', (tester) async { + testWidgets('CareScreen renders header, 4 quick action buttons, and filters', (tester) async { tester.view.physicalSize = const Size(800, 1600); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); @@ -37,33 +47,38 @@ void main() { await tester.pump(const Duration(milliseconds: 500)); // Verify Title & Subtitle - expect(find.text('Layanan & Komunitas'), findsOneWidget); - expect(find.text('Bantuan klinis profesional & safe space'), findsOneWidget); + expect(find.text('Layanan Konseling'), findsOneWidget); + expect(find.text('Konseling psikolog berlisensi & relawan sebaya'), findsOneWidget); - // Verify Community Banner Card - expect(find.byType(CommunityBannerCard), findsOneWidget); - expect(find.text('KOMUNITAS SAFE SPACE'), findsOneWidget); - expect(find.text('Gabung Sekarang'), findsOneWidget); + // Verify Community Banner is REMOVED + expect(find.byType(CommunityBannerCard), findsNothing); + expect(find.text('KOMUNITAS SAFE SPACE'), findsNothing); - // Verify Filter Chips + // Verify 4 Quick Action Buttons + expect(find.byType(CareQuickActions), findsOneWidget); + expect(find.text('Jadwal'), findsOneWidget); + expect(find.text('Riwayat'), findsOneWidget); + expect(find.text('Koin Saya'), findsOneWidget); + expect(find.text('Panduan'), findsOneWidget); + + // Verify Filter Chips: Semua, Psikolog, Relawan expect(find.text('Semua'), findsOneWidget); expect(find.text('Psikolog'), findsOneWidget); - expect(find.text('Psikiater'), findsOneWidget); - expect(find.text('Klinik Mitra'), findsOneWidget); + expect(find.text('Relawan'), findsWidgets); + expect(find.text('Psikolog Profesional'), findsNothing); + expect(find.text('Psikolog Freelance'), findsNothing); + expect(find.text('Volunteer'), findsNothing); - // Verify Psychologist Card rendered from mock data + // Verify Psychologist Cards rendered from mock data expect(find.byType(PsychologistCard), findsWidgets); - expect( - find.descendant( - of: find.byType(PsychologistCard), - matching: find.text('Amanda Zahra, M.Psi., Psikolog'), - ), - findsOneWidget, - ); - expect(find.text('SIPP Terdaftar'), findsWidgets); + expect(find.text('Amanda Zahra, M.Psi., Psikolog'), findsOneWidget); + + // Verify Tag chips rendered on cards (without volunteer explanation container) + expect(find.text('Profesional'), findsWidgets); + expect(find.text('Relawan'), findsWidgets); }); - testWidgets('Tapping Gabung Sekarang opens CommunityPaywallSheet', (tester) async { + testWidgets('Tapping Psikolog and Relawan filter chips filters counselors list accordingly', (tester) async { tester.view.physicalSize = const Size(800, 1600); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); @@ -73,20 +88,98 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); - // Tap on Gabung Sekarang button - final joinBtn = find.text('Gabung Sekarang'); - expect(joinBtn, findsOneWidget); - await tester.tap(joinBtn); + // Tap on Psikolog chip + final psikologChip = find.widgetWithText(ChoiceChip, 'Psikolog'); + expect(psikologChip, findsOneWidget); + await tester.tap(psikologChip); + await tester.pumpAndSettle(); + + // Verify psychologists are shown + expect(find.text('Amanda Zahra, M.Psi., Psikolog'), findsOneWidget); + expect(find.text('Dimas Wicaksono, M.Psi., Psikolog'), findsOneWidget); + + // Tap on Relawan chip + final relawanChip = find.widgetWithText(ChoiceChip, 'Relawan'); + expect(relawanChip, findsOneWidget); + await tester.tap(relawanChip); + await tester.pumpAndSettle(); + + // Verify volunteer counselors are shown + expect(find.text('Bayu Aditya, S.Psi.'), findsOneWidget); + }); + + testWidgets('Tapping psychologist card navigates to PsychologistDetailScreen with patient stats, 4 tags, and 5 testimonials', (tester) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(createWidgetUnderTest()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Tap on Amanda Zahra's card + final card = find.text('Amanda Zahra, M.Psi., Psikolog'); + expect(card, findsOneWidget); + await tester.tap(card); await tester.pumpAndSettle(); - // Verify Paywall Sheet is displayed - expect(find.byType(CommunityPaywallSheet), findsOneWidget); - expect(find.text('Eva Safe Space Community'), findsOneWidget); - expect(find.text('Akses Sekali Bayar Seumur Hidup'), findsOneWidget); - expect(find.text('Bonus 1 Bulan VIP Event Trial'), findsOneWidget); + // Verify PsychologistDetailScreen is displayed + expect(find.byType(PsychologistDetailScreen), findsOneWidget); + expect(find.text('Profil Konselor'), findsOneWidget); + + // 1. Verify Patient Statistics section + expect(find.text('Statistik Pasien Dirawat'), findsOneWidget); + expect(find.text('284+'), findsOneWidget); + expect(find.text('Pasien Dirawat'), findsOneWidget); + expect(find.text('98.6%'), findsOneWidget); + expect(find.text('Tingkat Puas'), findsOneWidget); + expect(find.text('3.8 Sesi'), findsOneWidget); + expect(find.text('96% Ulasan Positif'), findsOneWidget); + expect(find.text('4% Catatan / Masukan'), findsOneWidget); + expect(find.text('94.5% pasien merasakan penurunan kecemasan & stres kerja signifikan'), findsOneWidget); + + // 2. Verify Ulasan & Kesan Pasien is REMOVED (covered by Testimoni and Statistik) + expect(find.text('Ulasan & Kesan Pasien'), findsNothing); + + // 3. Verify Focus Issues & Payment Card in body are REMOVED + expect(find.text('FOKUS ISU & SPESIALISASI'), findsNothing); + expect(find.text('Biaya Sesi: 50 Koin EVA'), findsNothing); + + // 4. Verify 5 Patient Testimonials rendered with short review badges & descriptions + expect(find.text('TESTIMONI PASIEN'), findsOneWidget); + expect(find.text('5 ulasan pengalaman konseling pasien terverifikasi:'), findsOneWidget); + expect(find.text('Pasien Terverifikasi (A. Z.)'), findsOneWidget); + expect(find.text('Pasien Terverifikasi (R. K.)'), findsOneWidget); + expect(find.text('Pasien Terverifikasi (M. F.)'), findsOneWidget); + expect(find.text('Pasien Terverifikasi (N. D.)'), findsOneWidget); + expect(find.text('Pasien Terverifikasi (H. P.)'), findsOneWidget); + + // 5. Verify Clean Certificate Carousel in Detail Screen (Photo & Label) + expect(find.byType(PsychologistCertificateCarousel), findsOneWidget); + expect(find.text('Sertifikat & Lisensi'), findsOneWidget); + expect(find.text('1 / 4'), findsOneWidget); + expect(find.text('SIPP: 1988-12-04-2021 (HIMPSI)'), findsOneWidget); + expect(find.text('Perbesar'), findsWidgets); + + // 6. Verify Bottom Bar consultation fee & action button + expect(find.text('Biaya Konsultasi'), findsOneWidget); + expect(find.text('50 Koin'), findsOneWidget); + expect(find.text('Jadwalkan Konsultasi'), findsOneWidget); + + // 7. Verify "Lihat Semua" opens modal bottom sheet with filter tabs + final lihatSemuaBtn = find.text('Lihat Semua'); + expect(lihatSemuaBtn, findsOneWidget); + await tester.tap(lihatSemuaBtn); + await tester.pumpAndSettle(); + + expect(find.text('Semua Testimoni Pasien'), findsOneWidget); + expect(find.text('Semua (5)'), findsOneWidget); + expect(find.text('Positif (4)'), findsOneWidget); + expect(find.text('Catatan (1)'), findsOneWidget); }); - testWidgets('Switching filter to Klinik Mitra renders ClinicCards', (tester) async { + testWidgets('Tapping Jadwal navigates to ConsultationScheduleScreen and launches PsychologistVideoCallScreen', (tester) async { tester.view.physicalSize = const Size(800, 1600); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.resetPhysicalSize); @@ -96,16 +189,156 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); - // Tap on Klinik Mitra chip - final clinicChip = find.text('Klinik Mitra'); - expect(clinicChip, findsOneWidget); - await tester.tap(clinicChip); + final jadwalBtn = find.text('Jadwal'); + expect(jadwalBtn, findsOneWidget); + await tester.tap(jadwalBtn); + await tester.pumpAndSettle(); + + // Verify ConsultationScheduleScreen is displayed + expect(find.byType(ConsultationScheduleScreen), findsOneWidget); + expect(find.text('Jadwal Sesi Mendatang'), findsOneWidget); + expect(find.text('Waktunya Sesi Konsultasi!'), findsOneWidget); + + // 1. Verify that upcoming session outside session time is locked with "Belum Waktunya" + final lockedBtn = find.text('Belum Waktunya').first; + expect(lockedBtn, findsOneWidget); + await tester.tap(lockedBtn); + await tester.pumpAndSettle(); + + // Verify "Belum Waktunya Sesi" dialog appears preventing entry + expect(find.text('Belum Waktunya Sesi'), findsOneWidget); + expect(find.text('Mengerti'), findsOneWidget); + await tester.tap(find.text('Mengerti')); + await tester.pumpAndSettle(); + + // 2. Verify offline clinic session shows Maps card & Buka di Maps button + expect(find.byType(ClinicLocationMapCard), findsWidgets); + expect(find.text('Lokasi Konsultasi'), findsWidgets); + expect(find.text('Buka di Maps'), findsOneWidget); + + // 3. Verify Masuk Video Call button is present for active session and tap it + final videoCallBtn = find.text('Masuk Video Call Sekarang'); + expect(videoCallBtn, findsOneWidget); + await tester.tap(videoCallBtn); + await tester.pumpAndSettle(); + + // Verify PsychologistVideoCallScreen is launched + expect(find.byType(PsychologistVideoCallScreen), findsOneWidget); + expect(find.byIcon(Icons.call_end_rounded), findsOneWidget); + expect(find.byIcon(Icons.mic_rounded), findsOneWidget); + expect(find.byIcon(Icons.videocam_rounded), findsOneWidget); + + // 4. End the video call + final endCallBtn = find.byIcon(Icons.call_end_rounded); + await tester.tap(endCallBtn); + await tester.pumpAndSettle(); + + // In confirmation dialog, tap "Ya, Selesaikan" + final confirmEndBtn = find.text('Ya, Selesaikan'); + expect(confirmEndBtn, findsOneWidget); + await tester.tap(confirmEndBtn); + await tester.pumpAndSettle(); + + // 5. Verify ConsultationResultBottomSheet appears directly (not redirected to history) + expect(find.byType(ConsultationResultBottomSheet), findsOneWidget); + expect(find.text('Hasil & Saran Sesi Konsultasi'), findsOneWidget); + expect(find.text('RANGKUMAN HASIL KONSULTASI'), findsOneWidget); + expect(find.text('SARAN & REKOMENDASI PSIKOLOG'), findsOneWidget); + expect(find.text('LATIHAN MANDIRI (ACTION ITEMS)'), findsOneWidget); + + // Tap "Selesai & Tutup Saran Sesi" to close + final closeSheetBtn = find.text('Selesai & Tutup Saran Sesi'); + expect(closeSheetBtn, findsOneWidget); + await tester.tap(closeSheetBtn); + await tester.pumpAndSettle(); + }); + + testWidgets('Tapping Riwayat navigates to ConsultationHistoryScreen and shows hired psychologists & logs', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(createWidgetUnderTest()); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); - // Verify ClinicCards are displayed instead of PsychologistCards - expect(find.byType(ClinicCard), findsWidgets); - expect(find.text('Klinik Utama Sahabat Jiwa'), findsOneWidget); - expect(find.byType(PsychologistCard), findsNothing); + final historyBtn = find.text('Riwayat'); + expect(historyBtn, findsOneWidget); + await tester.tap(historyBtn); + await tester.pumpAndSettle(); + + // Verify ConsultationHistoryScreen is displayed + expect(find.byType(ConsultationHistoryScreen), findsOneWidget); + expect(find.text('Riwayat Bicara & Konseling'), findsOneWidget); + expect(find.text('PSIKOLOG YANG PERNAH DISEWA'), findsOneWidget); + + // Verify hired psychologists listed + expect(find.text('Amanda Zahra, M.Psi., Psikolog'), findsOneWidget); + expect(find.text('Dimas Wicaksono, M.Psi., Psikolog'), findsOneWidget); + expect(find.text('Rian Pratama, M.Psi., Psikolog'), findsOneWidget); + + // Tap on Lihat Log & Saran Sesi on Amanda Zahra's card + final lihatLogBtn = find.text('Lihat Log & Saran Sesi').first; + expect(lihatLogBtn, findsOneWidget); + await tester.tap(lihatLogBtn); + await tester.pumpAndSettle(); + + // Verify modal sheet opens with per-session logs and clinical advice + expect(find.text('Saran & Rekomendasi Psikolog:'), findsWidgets); + expect(find.text('Praktikkan teknik relaksasi napas 4-7-8 setiap malam 15 menit sebelum waktu tidur.'), findsOneWidget); + expect(find.text('LATIHAN MANDIRI (ACTION ITEMS)'), findsWidgets); + }); + + testWidgets('PsychologistCertificateCarousel displays certificates one by one and navigates via swipe and chevron buttons', (tester) async { + final certs = [ + 'SIPP: 1988-12-04-2021 (HIMPSI)', + 'Magister Profesi Psikologi Klinis Dewasa (S2)', + 'Sertifikasi CBT Terapan & Regulasi Emosi', + 'Sertifikat Psychological First Aid (PFA) Nasional', + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: PsychologistCertificateCarousel(certifications: certs), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Verify first certificate is shown with photo & clean label (no AI slop) + expect(find.text('1 / 4'), findsOneWidget); + expect(find.text('SIPP: 1988-12-04-2021 (HIMPSI)'), findsOneWidget); + expect(find.text('Perbesar'), findsOneWidget); + + // Tap next chevron button + final nextBtn = find.byIcon(Icons.chevron_right_rounded); + expect(nextBtn, findsOneWidget); + await tester.tap(nextBtn); + await tester.pumpAndSettle(); + + // Verify second certificate is displayed + expect(find.text('2 / 4'), findsOneWidget); + expect(find.text('Magister Profesi Psikologi Klinis Dewasa (S2)'), findsOneWidget); + + // Tap previous chevron button + final prevBtn = find.byIcon(Icons.chevron_left_rounded); + expect(prevBtn, findsOneWidget); + await tester.tap(prevBtn); + await tester.pumpAndSettle(); + + // Verify back to first certificate + expect(find.text('1 / 4'), findsOneWidget); + expect(find.text('SIPP: 1988-12-04-2021 (HIMPSI)'), findsOneWidget); + + // Swipe left on PageView to go to certificate 2 + await tester.drag(find.byType(PageView), const Offset(-300, 0)); + await tester.pumpAndSettle(); + + expect(find.text('2 / 4'), findsOneWidget); + expect(find.text('Magister Profesi Psikologi Klinis Dewasa (S2)'), findsOneWidget); }); } diff --git a/apps/eva_mobile/test/features/care/consultation_session_logs_screen_test.dart b/apps/eva_mobile/test/features/care/consultation_session_logs_screen_test.dart new file mode 100644 index 0000000..6812099 --- /dev/null +++ b/apps/eva_mobile/test/features/care/consultation_session_logs_screen_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:eva_mobile/config/app_config.dart'; +import 'package:eva_mobile/core/routes/app_router.dart'; +import 'package:eva_mobile/features/care/domain/entities/consultation_session_entity.dart'; +import 'package:eva_mobile/features/care/domain/entities/psychologist_entity.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_history_screen.dart'; +import 'package:eva_mobile/features/care/presentation/screens/consultation_session_logs_screen.dart'; + +void main() { + setUp(() { + AppConfig.useMockData = true; + SharedPreferences.setMockInitialValues({ + 'user_name': 'Alexander Agung', + 'user_email': 'alex@example.com', + 'user_coin_balance': 120, + }); + }); + + final testPsychologist = PsychologistEntity( + id: 'psy_test_1', + name: 'Dr. Sarah Amalia, M.Psi., Psikolog', + title: 'Psikolog Klinis Dewasa', + registrationNumber: 'STR-1994829102-PK', + avatarUrl: 'https://images.unsplash.com/photo-1594824813637-279c13b2e5a7?auto=format&fit=crop&q=80&w=300', + rating: 4.9, + reviewCount: 142, + experienceYears: 8, + specialties: const ['Depresi', 'Kecemasan', 'Manajemen Stres'], + coinsPerSession: 40, + durationMinutes: 50, + availableOnline: true, + bio: 'Berpengalaman menangani berbagai isu kesehatan mental klinis.', + practitionerType: PractitionerType.professional, + hasPsychologistCertificate: true, + isVolunteerRole: false, + ); + + final testSessions = [ + ConsultationSessionEntity( + id: 'session_test_1', + psychologist: testPsychologist, + sessionDateTime: DateTime(2026, 5, 10, 14, 0), + status: ConsultationStatus.completed, + type: ConsultationType.onlineVideo, + sessionNumber: 1, + durationMinutes: 50, + notes: 'Sering merasa cemas berlebihan dan insomnia menjelang deadline.', + diagnosisSummary: 'Kecemasan situasional tingkat sedang terpicu oleh beban kerja tinggi.', + adviceList: const [ + 'Lakukan teknik pernapasan 4-7-8 setiap kali mulai merasa overwhelmed.', + 'Batasi penggunaan layar minimal 45 menit sebelum tidur malam.', + ], + actionItems: const [ + 'Catat pemicu cemas harian di fitur Buku Harian AI.', + 'Lakukan jalan santai 15 menit setiap pagi.', + ], + moodBefore: 'Cemas & Gelisah', + moodAfter: 'Lebih Tenang & Lega', + ), + ]; + + testWidgets('ConsultationSessionLogsScreen renders title, psychologist card, advice, and bottom CTA', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + onGenerateRoute: AppRouter.onGenerateRoute, + home: ConsultationSessionLogsScreen( + psychologist: testPsychologist, + sessions: testSessions, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Verify Title & Header + expect(find.text('Log & Saran Sesi'), findsOneWidget); + expect(find.text('Catatan klinis & rekomendasi per sesi'), findsOneWidget); + + // Verify Psychologist Overview & chevron icon (no standalone 'Profil' button) + expect(find.text('Dr. Sarah Amalia, M.Psi., Psikolog'), findsOneWidget); + expect(find.text('Psikolog Klinis Dewasa'), findsOneWidget); + expect(find.text('Profil'), findsNothing); + expect(find.byIcon(Icons.chevron_right_rounded), findsOneWidget); + + // Verify Date Filter + expect(find.text('Semua Tanggal (1)'), findsOneWidget); + expect(find.text('10 Mei 2026'), findsOneWidget); + + // Verify Session Details + expect(find.text('Sesi #1'), findsOneWidget); + expect(find.text('50 Menit'), findsOneWidget); + expect(find.text('"Sering merasa cemas berlebihan dan insomnia menjelang deadline."'), findsOneWidget); + expect(find.text('Kecemasan situasional tingkat sedang terpicu oleh beban kerja tinggi.'), findsOneWidget); + expect(find.text('Lakukan teknik pernapasan 4-7-8 setiap kali mulai merasa overwhelmed.'), findsOneWidget); + expect(find.text('Catat pemicu cemas harian di fitur Buku Harian AI.'), findsOneWidget); + + // Verify Bottom Action Button + expect(find.text('Jadwalkan Sesi Baru Bersama Dr. Sarah Amalia'), findsOneWidget); + }); + + testWidgets('ConsultationHistoryScreen navigates to ConsultationSessionLogsScreen when pressing Lihat Log & Saran Sesi', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + onGenerateRoute: AppRouter.onGenerateRoute, + home: ConsultationHistoryScreen(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Verify Screen Title + expect(find.text('Riwayat Bicara & Konseling'), findsOneWidget); + + // Find and tap "Lihat Log & Saran Sesi" + final buttonFinder = find.text('Lihat Log & Saran Sesi'); + expect(buttonFinder, findsWidgets); + + await tester.tap(buttonFinder.first); + await tester.pumpAndSettle(); + + // Verify that we arrived on the new dedicated ConsultationSessionLogsScreen + expect(find.text('Log & Saran Sesi'), findsOneWidget); + expect(find.text('Catatan klinis & rekomendasi per sesi'), findsOneWidget); + }); + + testWidgets('ConsultationHistoryScreen search bar filters psychologists and shows empty state on unmatched query', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + onGenerateRoute: AppRouter.onGenerateRoute, + home: ConsultationHistoryScreen(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + // Verify search bar is rendered + final searchFinder = find.byType(TextField); + expect(searchFinder, findsOneWidget); + expect(find.text('Cari nama psikolog atau relawan...'), findsOneWidget); + + // Search for a non-existent name + await tester.enterText(searchFinder, 'NamaTidakAdaXYZ'); + await tester.pump(); + + // Verify search empty state + expect(find.text('Tidak Ditemukan'), findsOneWidget); + expect(find.text('Reset Pencarian'), findsOneWidget); + + // Tap reset + await tester.tap(find.text('Reset Pencarian')); + await tester.pump(); + + // Verify items are restored + expect(find.text('Tidak Ditemukan'), findsNothing); + }); +} diff --git a/apps/eva_mobile/test/features/coin/coin_feature_test.dart b/apps/eva_mobile/test/features/coin/coin_feature_test.dart new file mode 100644 index 0000000..2a9a728 --- /dev/null +++ b/apps/eva_mobile/test/features/coin/coin_feature_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:eva_mobile/features/coin/domain/entities/coin_package_entity.dart'; +import 'package:eva_mobile/features/coin/presentation/providers/coin_provider.dart'; +import 'package:eva_mobile/features/coin/presentation/screens/coin_purchase_screen.dart'; +import 'package:eva_mobile/features/home/presentation/widgets/profile_dropdown_modal.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('CoinPackageEntity tests', () { + test('Calculates totalCoins correctly with bonus', () { + const pkg = CoinPackageEntity( + id: 'pkg_test', + title: 'Paket Tes', + coins: 100, + bonusCoins: 25, + priceIdr: 50000, + ); + expect(pkg.totalCoins, 125); + expect(pkg.formattedPrice, 'Rp 50.000'); + }); + + test('Calculates formattedPrice for large amount', () { + const pkg = CoinPackageEntity( + id: 'pkg_large', + title: 'Paket Besar', + coins: 1000, + priceIdr: 250000, + ); + expect(pkg.totalCoins, 1000); + expect(pkg.formattedPrice, 'Rp 250.000'); + }); + }); + + group('CoinBalanceNotifier Provider tests', () { + test('Initializes with default balance and can add/spend coins', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(coinBalanceProvider), kDefaultCoinBalance); + + // Add coins + await container.read(coinBalanceProvider.notifier).addCoins(50); + expect(container.read(coinBalanceProvider), kDefaultCoinBalance + 50); + + // Spend coins + final success = await container.read(coinBalanceProvider.notifier).spendCoins(30); + expect(success, isTrue); + expect(container.read(coinBalanceProvider), kDefaultCoinBalance + 20); + + // Insufficient spend + final fail = await container.read(coinBalanceProvider.notifier).spendCoins(99999); + expect(fail, isFalse); + expect(container.read(coinBalanceProvider), kDefaultCoinBalance + 20); + }); + }); + + group('ProfileDropdownModal Widget tests', () { + testWidgets('Displays coin balance with purple theme and triggers onCoinTap', (tester) async { + bool coinTapped = false; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Scaffold( + body: ProfileDropdownModal( + userName: 'Samsul Arifin', + userEmail: 'samsul@example.com', + coinBalance: 250, + onProfileTap: () {}, + onEmergencyContactsTap: () {}, + onSettingsTap: () {}, + onLogoutTap: () {}, + onCoinTap: () { + coinTapped = true; + }, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify user info is displayed + expect(find.text('Samsul Arifin'), findsOneWidget); + + // Verify coin balance is shown instead of Upgrade profile + expect(find.text('250 Koin'), findsOneWidget); + expect(find.text('Saldo Koin'), findsOneWidget); + expect(find.text('Beli'), findsOneWidget); + expect(find.text('Upgrade profile'), findsNothing); + + // Tap on the coin banner + await tester.tap(find.text('250 Koin')); + await tester.pumpAndSettle(); + + expect(coinTapped, isTrue); + }); + }); + + group('CoinPurchaseScreen Widget tests', () { + testWidgets('Renders balance, packages, payment methods, and purchase button', (tester) async { + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: CoinPurchaseScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Check header + expect(find.text('Beli Koin EVA'), findsOneWidget); + + // Check default balance hero card + expect(find.text('$kDefaultCoinBalance Koin'), findsOneWidget); + + // Check packages + expect(find.text('Paket Starter'), findsOneWidget); + expect(find.text('Paket Favorit'), findsOneWidget); + expect(find.text('Paket Ultimate'), findsOneWidget); + + // Check updated benefits + expect(find.text('Bisa Berbicara Langsung dengan EVA'), findsOneWidget); + expect(find.text('Alat Transaksi Konsultasi Psikolog'), findsOneWidget); + + // Check purchase button + expect(find.text('Beli Sekarang'), findsOneWidget); + }); + }); +} diff --git a/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart b/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart index 96ab9de..6782c6b 100644 --- a/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart +++ b/apps/eva_mobile/test/features/tutorial/branded_guidance_card_test.dart @@ -66,5 +66,32 @@ void main() { await tester.tap(find.text('Lanjut')); expect(nextClicked, isTrue); }); + + testWidgets('renders custom Selesai button when nextButtonLabel is Selesai', + (tester) async { + bool finishClicked = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: BrandedGuidanceCard( + title: 'Layanan & Dukungan Sebaya', + description: + 'Jika butuh teman bicara manusia, temukan konseling profesional di sini.', + isInteractiveTarget: false, + nextButtonLabel: 'Selesai', + onSkip: () {}, + onNext: () => finishClicked = true, + ), + ), + ), + ), + ); + + expect(find.text('Selesai'), findsOneWidget); + await tester.tap(find.text('Selesai')); + expect(finishClicked, isTrue); + }); }); } diff --git a/apps/eva_mobile/test/features/tutorial/tutorial_provider_test.dart b/apps/eva_mobile/test/features/tutorial/tutorial_provider_test.dart index 2efe79b..dd3d719 100644 --- a/apps/eva_mobile/test/features/tutorial/tutorial_provider_test.dart +++ b/apps/eva_mobile/test/features/tutorial/tutorial_provider_test.dart @@ -53,14 +53,28 @@ void main() { await Future.delayed(const Duration(milliseconds: 10)); final notifier = container.read(tutorialNotifierProvider.notifier); - // Voice session target tapped -> sets step to journal, pauses overlay for call + // Voice session target tapped -> sets step to voiceCoins for phone coin tutorial await notifier.onTargetTapped(TutorialStep.voiceSession); expect(container.read(tutorialNotifierProvider).currentStep, - TutorialStep.journal); + TutorialStep.voiceCoins); + expect(container.read(tutorialNotifierProvider).isActive, isTrue); + + // Voice coins acknowledged -> sets step to voiceStart + await notifier.onTargetTapped(TutorialStep.voiceCoins); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.voiceStart); + expect(container.read(tutorialNotifierProvider).isActive, isTrue); + + // Voice start tapped -> pauses overlay for active phone call + await notifier.onTargetTapped(TutorialStep.voiceStart); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.voiceCompleted); expect(container.read(tutorialNotifierProvider).isActive, isFalse); - // Resume at journal - notifier.resumeAtStep(TutorialStep.journal); + // Voice completed (user taps Lihat Detail Jurnal Refleksi) -> sets step to journal + await notifier.onTargetTapped(TutorialStep.voiceCompleted); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.journal); expect(container.read(tutorialNotifierProvider).isActive, isTrue); // Next step -> analytics @@ -68,6 +82,16 @@ void main() { expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.analytics); + // Next step -> dassDetail + notifier.nextStep(); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.dassDetail); + + // Next step -> dassItem + notifier.nextStep(); + expect(container.read(tutorialNotifierProvider).currentStep, + TutorialStep.dassItem); + // Next step -> care notifier.nextStep(); expect( @@ -79,6 +103,47 @@ void main() { expect(container.read(tutorialNotifierProvider).isActive, isFalse); }); + test('onTargetTapped advances through full flow including dassDetail and care', () async { + final repo = _FakeTutorialRepository(); + final container = ProviderContainer( + overrides: [ + tutorialRepositoryProvider.overrideWithValue(repo), + ], + ); + addTearDown(container.dispose); + + await Future.delayed(const Duration(milliseconds: 10)); + final notifier = container.read(tutorialNotifierProvider.notifier); + + await notifier.onTargetTapped(TutorialStep.voiceSession); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.voiceCoins); + + await notifier.onTargetTapped(TutorialStep.voiceCoins); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.voiceStart); + + await notifier.onTargetTapped(TutorialStep.voiceStart); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.voiceCompleted); + + await notifier.onTargetTapped(TutorialStep.voiceCompleted); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.journal); + + await notifier.onTargetTapped(TutorialStep.journal); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.analytics); + + await notifier.onTargetTapped(TutorialStep.analytics); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.dassDetail); + + await notifier.onTargetTapped(TutorialStep.dassDetail); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.dassItem); + + await notifier.onTargetTapped(TutorialStep.dassItem); + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.care); + + await notifier.onTargetTapped(TutorialStep.care); + expect(container.read(tutorialNotifierProvider).isCompleted, isTrue); + expect(container.read(tutorialNotifierProvider).isActive, isFalse); + }); + test('skipTutorial dismisses for current session, ensureActiveOnLaunch resets on launch', () async { final repo = _FakeTutorialRepository(); final container = ProviderContainer( diff --git a/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart b/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart index 048b650..fa16f54 100644 --- a/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart +++ b/apps/eva_mobile/test/screens/ai_conversation_screen_test.dart @@ -7,6 +7,9 @@ import 'package:eva_mobile/features/chat/domain/entities/conversation_entity.dar import 'package:eva_mobile/features/chat/domain/repositories/chat_repository.dart'; import 'package:eva_mobile/features/chat/presentation/providers/chat_provider.dart'; import 'package:eva_mobile/features/chat/presentation/screens/ai_conversation_screen.dart'; +import 'package:eva_mobile/features/tutorial/domain/entities/tutorial_step.dart'; +import 'package:eva_mobile/features/tutorial/presentation/providers/tutorial_provider.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; import 'package:eva_mobile/widgets/glass_card.dart'; class _PendingChatRepository implements ChatRepository { @@ -103,5 +106,73 @@ void main() { ); expect(skeletonFinder, findsNWidgets(10)); }); + + testWidgets('renders coin balance badge with + icon and no logo icon to the left of title', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: AiConversationScreen(), + ), + ), + ); + + await tester.pump(); + + // Verify title exists + expect(find.text('Sesi Suara EVA'), findsOneWidget); + + // Verify nightlight icon to the left of the title does not exist + expect(find.byIcon(Icons.nightlight_round), findsNothing); + + // Verify coin badge with default coin balance (120) and + icon + expect(find.text('120'), findsOneWidget); + expect(find.byIcon(Icons.monetization_on_rounded), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget); + }); + + testWidgets('shows tutorial spotlight on coin badge at TutorialStep.voiceCoins and moves to Mulai Sesi Suara', (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final container = ProviderContainer(); + addTearDown(container.dispose); + + // Set tutorial to voiceCoins step + container.read(tutorialNotifierProvider.notifier).resumeAtStep(TutorialStep.voiceCoins); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: AiConversationScreen(), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Verify spotlight overlay for Telepon Pakai Koin is visible + expect(find.byType(InteractiveSpotlightOverlay), findsOneWidget); + expect(find.text('Telepon Pakai Koin'), findsOneWidget); + expect(find.text('INFO KOIN'), findsOneWidget); + + // Advance to next step (Mulai Sesi Suara) + await tester.tap(find.text('Lanjut')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Verify spotlight overlay advanced to Mulai Sesi Suara + expect(container.read(tutorialNotifierProvider).currentStep, TutorialStep.voiceStart); + expect(find.text('Mulai Sesi Suara'), findsWidgets); + expect(find.text('SESI SUARA'), findsOneWidget); + }); } diff --git a/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart b/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart index ac7f8e0..32c91d6 100644 --- a/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart +++ b/apps/eva_mobile/test/screens/main_shell_and_call_screen_test.dart @@ -5,6 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:eva_mobile/features/voice_call/presentation/providers/ai_call_provider.dart'; import 'package:eva_mobile/features/home/presentation/screens/main_shell_screen.dart'; import 'package:eva_mobile/features/voice_call/presentation/screens/voice_call_screen.dart'; +import 'package:eva_mobile/features/tutorial/presentation/widgets/interactive_spotlight_overlay.dart'; import 'package:eva_mobile/widgets/floating_nav_bar.dart'; void main() { @@ -162,5 +163,51 @@ void main() { expect(receivedArgs, isNotNull); expect(receivedArgs!['initialIndex'], 1); }); + + testWidgets('shows interactive spotlight overlay on Lihat Detail Jurnal Refleksi during tutorial', + (tester) async { + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + Map? receivedArgs; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + routes: { + '/home': (context) { + receivedArgs = ModalRoute.of(context)?.settings.arguments + as Map?; + return const Scaffold(body: Text('Home Screen Destination')); + }, + }, + home: const VoiceCallScreen(), + ), + ), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // End call + await tester.tap(find.byIcon(Icons.call_end)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + + // Spotlight overlay is present on the bottom sheet + expect(find.byType(InteractiveSpotlightOverlay), findsOneWidget); + expect(find.text('Refleksi Sesi Suara'), findsOneWidget); + expect(find.text('REFLEKSI AI'), findsOneWidget); + + // Tap "Lihat Detail Jurnal Refleksi" + await tester.tap(find.text('Lihat Detail Jurnal Refleksi')); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Home Screen Destination'), findsOneWidget); + expect(receivedArgs!['initialIndex'], 1); + }); }); } diff --git a/apps/eva_mobile/test/widgets/app_snack_bar_test.dart b/apps/eva_mobile/test/widgets/app_snack_bar_test.dart index 88df07a..694424f 100644 --- a/apps/eva_mobile/test/widgets/app_snack_bar_test.dart +++ b/apps/eva_mobile/test/widgets/app_snack_bar_test.dart @@ -66,7 +66,7 @@ void main() { expect(find.text('Sukses Simpan'), findsOneWidget); expect(find.byIcon(Icons.check_circle_outline_rounded), findsOneWidget); var snackBar = tester.widget(find.byType(SnackBar)); - expect(snackBar.backgroundColor, const Color(0xFF10B981)); + expect(snackBar.backgroundColor, AppColors.success); // Test Warning (replaces previous snackbar immediately) await tester.tap(find.text('Warning')); diff --git a/scripts/run_mobile.sh b/scripts/run_mobile.sh index dbd93fb..fbe58d1 100755 --- a/scripts/run_mobile.sh +++ b/scripts/run_mobile.sh @@ -21,28 +21,81 @@ RED="\033[0;31m" CYAN="\033[0;36m" NC="\033[0m" -MODE="${1:-}" +RAW_MODE="${1:-}" +TARGET_ENV="${ENV:-${TARGET:-local}}" +FALLBACK_HYBRID="${FALLBACK_HYBRID:-false}" + +case "${RAW_MODE}" in + usb-cloud) + MODE="usb" + TARGET_ENV="cloud" + ;; + emu-cloud) + MODE="emu" + TARGET_ENV="cloud" + ;; + web-cloud) + MODE="web" + TARGET_ENV="cloud" + ;; + cloud) + MODE="usb" + TARGET_ENV="cloud" + ;; + usb-hybrid) + MODE="usb" + FALLBACK_HYBRID=true + ;; + emu-hybrid) + MODE="emu" + FALLBACK_HYBRID=true + ;; + web-hybrid) + MODE="web" + FALLBACK_HYBRID=true + ;; + hybrid) + MODE="usb" + FALLBACK_HYBRID=true + ;; + *) + MODE="${RAW_MODE}" + ;; +esac + PORT="${PORT:-8888}" MCP_PORT="${MCP_PORT:-8889}" usage() { - echo -e "${BOLD}Usage:${NC} $0 [extra_flutter_args...]" + echo -e "${BOLD}Usage:${NC} $0 [extra_flutter_args...]" + echo "" + echo "Modes (DEFAULT: Nembak Local API port ${PORT}):" + echo " usb Jalankan di HP Fisik via USB (DEFAULT: API Lokal via reverse port ${PORT})" + echo " emu Jalankan di Android Emulator (DEFAULT: API Lokal via 10.0.2.2:${PORT})" + echo " web Jalankan di Web Browser Chrome (DEFAULT: API Lokal via 127.0.0.1:${PORT})" + echo " mock Jalankan dengan data simulasi lokal (USE_MOCK_DATA=true, offline)" + echo " hybrid Jalankan mode Hybrid (Remote API dengan fallback otomatis ke Mock)" + echo "" + echo "Hybrid Fallback Modes (Remote API + Auto Mock Fallback on 404/Offline):" + echo " usb-hybrid HP Fisik dengan Hybrid Fallback" + echo " emu-hybrid Android Emulator dengan Hybrid Fallback" + echo " web-hybrid Chrome Web dengan Hybrid Fallback" echo "" - echo "Modes:" - echo " usb Jalankan Eva Mobile di HP Fisik via USB (otomatis adb reverse port ${PORT})" - echo " emu Jalankan Eva Mobile di Android Emulator (route 10.0.2.2:${PORT})" - echo " mock Jalankan Eva Mobile dengan data simulasi lokal (USE_MOCK_DATA=true)" - echo " web Jalankan Eva Mobile di Web Browser (Chrome)" + echo "Cloud Modes (Nembak Production Cloud eva.nexacode.dev):" + echo " usb-cloud Jalankan di HP Fisik nembak ke Cloud (eva.nexacode.dev)" + echo " emu-cloud Jalankan di Android Emulator nembak ke Cloud (eva.nexacode.dev)" + echo " web-cloud Jalankan di Web Browser nembak ke Cloud (eva.nexacode.dev)" echo "" echo "Examples:" - echo " $0 usb # Eva Mobile di HP fisik dengan reverse port ${PORT}" - echo " $0 emu # Eva Mobile di Android Emulator" - echo " $0 mock # Eva Mobile dengan Mock Data" - echo " $0 web # Eva Mobile di Chrome" + echo " $0 usb # HP fisik nembak lokal port ${PORT}" + echo " $0 hybrid # HP fisik dengan mode Hybrid Fallback" + echo " $0 emu-hybrid # Emulator dengan mode Hybrid Fallback" + echo " $0 mock # Eva Mobile dengan Mock Data murni" echo "" echo "Environment Variables:" - echo " DEVICE Tentukan Device ID spesifik (misal: DEVICE=RR8Y900W1RB)" - echo " PORT Port backend FastAPI (default: 8888)" + echo " DEVICE Tentukan Device ID spesifik (misal: DEVICE=RR8Y900W1RB)" + echo " PORT Port backend FastAPI lokal (default: 8888)" + echo " TARGET_ENV Tentukan target server ('local' atau 'cloud')" exit 1 } @@ -88,12 +141,13 @@ fi shift 1 || true check_backend_health() { + echo -e "${CYAN}Memeriksa status backend lokal...${NC}" if command -v curl &>/dev/null; then - if ! curl -s --connect-timeout 2 "http://localhost:${PORT}/health" &>/dev/null; then - echo -e "${YELLOW}⚠️ Perhatian:${NC} Backend EVA di http://localhost:${PORT} belum merespons." - echo -e " Pastikan backend sudah berjalan via ${BOLD}make dev${NC} atau ${BOLD}make dev-docker${NC}.\n" + if ! curl -s --connect-timeout 2 "http://127.0.0.1:${PORT}/health" &>/dev/null; then + echo -e "${YELLOW}⚠️ Perhatian:${NC} Backend EVA di http://127.0.0.1:${PORT} belum merespons." + echo -e " Pastikan backend sudah berjalan via ${BOLD}make dev${NC} atau ${BOLD}make dev-d${NC}.\n" else - echo -e "${GREEN}✓ Backend EVA aktif di port ${PORT}.${NC}" + echo -e "${GREEN}✓ Backend EVA aktif di port ${PORT}.${NC}\n" fi fi } @@ -102,11 +156,17 @@ check_backend_health() { # MODE: USB (Physical Device) # ============================================================================== if [ "${MODE}" = "usb" ]; then - echo -e "\n${BOLD}${CYAN}================================================================================${NC}" - echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — HP FISIK (USB DEBUGGING) ${NC}" - echo -e "${BOLD}${CYAN}================================================================================${NC}\n" - - check_backend_health + if [ "${TARGET_ENV}" = "cloud" ]; then + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — HP FISIK (CLOUD: eva.nexacode.dev) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + echo -e "${GREEN}🌐 Menghubungkan ke Server Production Cloud...${NC}\n" + else + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — HP FISIK (LOCAL API: 127.0.0.1:${PORT}) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + check_backend_health + fi # Check for unauthorized devices if "${ADB_BIN}" devices | grep -q "unauthorized"; then @@ -138,30 +198,59 @@ if [ "${MODE}" = "usb" ]; then fi echo -e "${CYAN}Target Device:${NC} ${BOLD}${TARGET_DEVICE}${NC} (Eva Mobile)" - echo -e "${CYAN}Konfigurasi Port Forwarding:${NC} Menghubungkan port ${PORT} & ${MCP_PORT} HP -> PC..." - # Perform adb reverse for API & MCP - if "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${PORT}" "tcp:${PORT}"; then - echo -e "${GREEN}✓ Berhasil reverse port API:${NC} tcp:${PORT} -> tcp:${PORT} pada ${TARGET_DEVICE}" - else - echo -e "${RED}[ERROR] Gagal melakukan adb reverse port ${PORT}.${NC}" - exit 1 + if [ "${TARGET_ENV}" != "cloud" ]; then + echo -e "${CYAN}Konfigurasi Port Forwarding:${NC} Menghubungkan port ${PORT} & ${MCP_PORT} HP -> PC..." + # Perform adb reverse for API & MCP + if "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${PORT}" "tcp:${PORT}"; then + echo -e "${GREEN}✓ Berhasil reverse port API:${NC} tcp:${PORT} -> tcp:${PORT} pada ${TARGET_DEVICE}" + else + echo -e "${RED}[ERROR] Gagal melakukan adb reverse port ${PORT}.${NC}" + exit 1 + fi + "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${MCP_PORT}" "tcp:${MCP_PORT}" 2>/dev/null || true + fi + + if [ "${FALLBACK_HYBRID}" = "true" ]; then + echo -e "${GREEN}✓ Hybrid Fallback diaktifkan (Auto-mock pada 404 / server offline).${NC}\n" fi - "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${MCP_PORT}" "tcp:${MCP_PORT}" 2>/dev/null || true - echo -e "\n${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di ${TARGET_DEVICE}...${NC}\n" + echo -e "${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di ${TARGET_DEVICE}...${NC}" + echo -e "${CYAN}Mengompilasi dan memulai aplikasi (Gradle assembleDebug), mohon tunggu...${NC}\n" cd "${APP_DIR}" - exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" --dart-define=USE_LOCALHOST=true "$@" + if [ "${TARGET_ENV}" = "cloud" ]; then + exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" \ + --dart-define=USE_LOCAL_API=false \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + else + exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" \ + --dart-define=USE_LOCAL_API=true \ + --dart-define=LOCAL=true \ + --dart-define=USE_LOCALHOST=true \ + --dart-define=LOCAL_HOST="127.0.0.1:${PORT}" \ + --dart-define=API_BASE_URL="http://127.0.0.1:${PORT}/api/v1" \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + fi # ============================================================================== # MODE: EMU (Android Emulator) # ============================================================================== elif [ "${MODE}" = "emu" ]; then - echo -e "\n${BOLD}${CYAN}================================================================================${NC}" - echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — ANDROID EMULATOR ${NC}" - echo -e "${BOLD}${CYAN}================================================================================${NC}\n" - - check_backend_health + if [ "${TARGET_ENV}" = "cloud" ]; then + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — ANDROID EMULATOR (CLOUD: eva.nexacode.dev) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + echo -e "${GREEN}🌐 Menghubungkan ke Server Production Cloud...${NC}\n" + else + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — ANDROID EMULATOR (LOCAL API: 10.0.2.2:${PORT}) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + check_backend_health + fi # Detect running emulators mapfile -t EMU_DEVICES < <("${ADB_BIN}" devices | awk '$2=="device" && $1~/^emulator-/ {print $1}') @@ -186,13 +275,37 @@ elif [ "${MODE}" = "emu" ]; then echo -e "${CYAN}Target Emulator:${NC} ${BOLD}${TARGET_DEVICE}${NC} (Eva Mobile)" - # Also reverse port on emulator as safety measure - "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${PORT}" "tcp:${PORT}" 2>/dev/null || true - echo -e "${GREEN}✓ Reverse port & bridge alias 10.0.2.2:${PORT} siap.${NC}" + if [ "${TARGET_ENV}" != "cloud" ]; then + # Also reverse port on emulator as safety measure + "${ADB_BIN}" -s "${TARGET_DEVICE}" reverse "tcp:${PORT}" "tcp:${PORT}" 2>/dev/null || true + echo -e "${GREEN}✓ Reverse port & bridge alias 10.0.2.2:${PORT} siap.${NC}" + fi - echo -e "\n${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di emulator ${TARGET_DEVICE}...${NC}\n" + if [ "${FALLBACK_HYBRID}" = "true" ]; then + echo -e "${GREEN}✓ Hybrid Fallback diaktifkan (Auto-mock pada 404 / server offline).${NC}\n" + fi + + echo -e "${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di emulator ${TARGET_DEVICE}...${NC}" + echo -e "${CYAN}Mengompilasi dan memulai aplikasi (Gradle assembleDebug), mohon tunggu...${NC}\n" cd "${APP_DIR}" - exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" --dart-define=API_BASE_URL="http://10.0.2.2:${PORT}/api/v1" "$@" + if [ "${TARGET_ENV}" = "cloud" ]; then + exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" \ + --dart-define=USE_LOCAL_API=false \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + else + exec "${FLUTTER_BIN}" run -d "${TARGET_DEVICE}" \ + --dart-define=USE_LOCAL_API=true \ + --dart-define=LOCAL=true \ + --dart-define=IS_EMULATOR=true \ + --dart-define=EMULATOR=true \ + --dart-define=LOCAL_HOST="10.0.2.2:${PORT}" \ + --dart-define=API_BASE_URL="http://10.0.2.2:${PORT}/api/v1" \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + fi # ============================================================================== # MODE: MOCK (Local Mock Data Mode) @@ -211,14 +324,40 @@ elif [ "${MODE}" = "mock" ]; then # MODE: WEB (Chrome Browser Simulator) # ============================================================================== elif [ "${MODE}" = "web" ]; then - echo -e "\n${BOLD}${CYAN}================================================================================${NC}" - echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — WEB SIMULATOR (CHROME) ${NC}" - echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + if [ "${TARGET_ENV}" = "cloud" ]; then + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — WEB SIMULATOR (CLOUD: eva.nexacode.dev) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + echo -e "${GREEN}🌐 Menghubungkan ke Server Production Cloud...${NC}\n" + else + echo -e "\n${BOLD}${CYAN}================================================================================${NC}" + echo -e "${BOLD}${CYAN} EVA MOBILE RUNNER — WEB SIMULATOR (LOCAL API: 127.0.0.1:${PORT}) ${NC}" + echo -e "${BOLD}${CYAN}================================================================================${NC}\n" + check_backend_health + fi - check_backend_health - echo -e "\n${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di Chrome...${NC}\n" + if [ "${FALLBACK_HYBRID}" = "true" ]; then + echo -e "${GREEN}✓ Hybrid Fallback diaktifkan (Auto-mock pada 404 / server offline).${NC}\n" + fi + + echo -e "${BOLD}${GREEN}🚀 Menjalankan Eva Mobile di Chrome...${NC}\n" cd "${APP_DIR}" - exec "${FLUTTER_BIN}" run -d chrome "$@" + if [ "${TARGET_ENV}" = "cloud" ]; then + exec "${FLUTTER_BIN}" run -d chrome \ + --dart-define=USE_LOCAL_API=false \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + else + exec "${FLUTTER_BIN}" run -d chrome \ + --dart-define=USE_LOCAL_API=true \ + --dart-define=LOCAL=true \ + --dart-define=LOCAL_HOST="127.0.0.1:${PORT}" \ + --dart-define=API_BASE_URL="http://127.0.0.1:${PORT}/api/v1" \ + --dart-define=USE_HYBRID_FALLBACK="${FALLBACK_HYBRID}" \ + --dart-define=HYBRID="${FALLBACK_HYBRID}" \ + "$@" + fi else usage From 3b4cb718f5ba26a262643d24225f64f6989ba17e Mon Sep 17 00:00:00 2001 From: ekyaaa Date: Sun, 8 Nov 2026 09:17:53 +0700 Subject: [PATCH 7/7] chore(agentic): update coordination rules, project sync scripts, and task state --- .agent/task-state.json | 9 + .agents/AGENTS.md | 5 +- .agents/rules/abstraction-and-mock-prod.md | 15 +- .agents/rules/agentic-workflow-constraints.md | 1 + .agents/rules/cohesion-and-modularity.md | 26 ++- .agents/rules/github-coordination.md | 1 + .agents/rules/native-gh-execution.md | 34 ++-- .agents/skills/flutter-workflow/SKILL.md | 22 +-- .agents/skills/manage-github-project/SKILL.md | 133 ++++++++++++++- .agents/skills/monorepo-ops/SKILL.md | 9 +- .agents/skills/project-state-sync/SKILL.md | 1 + .agents/skills/sync-project-state/SKILL.md | 3 + AGENTS.md | 5 +- Makefile | 44 ++++- scripts/agentic/gh_project.py | 161 ++++++++++++++++++ tests/agentic/test_gh_project.py | 90 ++++++++++ 16 files changed, 502 insertions(+), 57 deletions(-) create mode 100644 .agent/task-state.json diff --git a/.agent/task-state.json b/.agent/task-state.json new file mode 100644 index 0000000..ec1b1ab --- /dev/null +++ b/.agent/task-state.json @@ -0,0 +1,9 @@ +{ + "28": { + "issue": 28, + "branch": "feat/28-eva-mobile-client", + "state": "In Progress", + "updated_at": 1793875471, + "evidence_summary": "Evidence accepted: active task branch verified." + } +} \ No newline at end of file diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index a0a5e0e..0f2cfc4 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -32,9 +32,11 @@ - **Sweet-Spot Cohesion & Modularity**: - Keep files cohesive; extract independent timers, custom painters, and multi-step modals into dedicated widget files while keeping local layout together. - See [.agents/rules/cohesion-and-modularity.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/cohesion-and-modularity.md) for natural separation criteria. +- **Component Reuse & Single Source of Truth**: + - Before creating new UI elements or duplicate styling, always verify and reuse existing widgets in `apps/eva_mobile/lib/widgets/` (e.g. `AppBackButton`, `GlassCard`, `AppSnackBar`) or feature widgets. Never declare manual inline back buttons across screens. - **Hierarchical Workspace Scopes**: - Flutter Mobile: Follow [apps/eva_mobile/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/eva_mobile/AGENTS.md) (Clean Architecture, Riverpod DI, Pure Dart models). - - Backend API: Follow [apps/backend/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/backend/AGENTS.md) (FastAPI routes, Pydantic schemas, Alembic migrations, FastMCP). + - Backend API: Follow [apps/backend/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/backend/AGENTS.md) and [.agents/rules/backend-clean-architecture.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/backend-clean-architecture.md) (Thin routes 3-5 lines, modular services/schemas, centralized DI, Alembic migrations). - AI & Orchestration: Follow [packages/ai/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/packages/ai/AGENTS.md) (Abstract Base Classes, LLM/TTS factories, graceful fallbacks). - **Available Skills**: - `monorepo-ops`: Docker dev/prod, Alembic migrations, database resets, and backend service runner. @@ -61,6 +63,7 @@ ## 5. Supervised Autonomous Development Coordination Policies - **Human-Owned Tasks**: Every task has a single human developer owner. AI agents code only on the task selected by the developer and must never auto-claim or auto-assign tasks. +- **Issue Mutation Review Gate**: The AI agent must never create, edit, close, or delete GitHub Issues autonomously. All additions, updates, or deletions must be presented to the developer (via draft/dry-run) and receive explicit review and approval before execution. - **Identity Preflight Mandatory**: Before modifying code or updating task status, verify local identity via `python3 scripts/agentic/doctor.py`. If identity is missing, prompt developer to run `python3 scripts/agentic/bootstrap.py`. - **Session Context Reconciliation**: At the beginning of a session, inspect active context via `python3 scripts/agentic/tasks.py` to reconcile local and remote state. - **Strict Branch & Worktree Naming**: All branches must follow `{type}/{issue-number}-{short-kebab-slug}` where type is one of `feat`, `fix`, `refactor`, `docs`, `test`, `chore`. Personal branches (`dev/{name}`) are prohibited. diff --git a/.agents/rules/abstraction-and-mock-prod.md b/.agents/rules/abstraction-and-mock-prod.md index f64562d..aadf3c5 100644 --- a/.agents/rules/abstraction-and-mock-prod.md +++ b/.agents/rules/abstraction-and-mock-prod.md @@ -122,17 +122,18 @@ class LLMFactory: ``` ### 2.3 Dependency Injection di FastAPI Endpoints -Endpoint di `apps/backend/api/app/api/routes` **DILARANG KERAS** menginisialisasi provider atau service konkret secara manual di dalam handler fungsi. Gunakan `Depends()`: +Endpoint di `apps/backend/api/app/api/routes` **DILARANG KERAS** menginisialisasi provider atau service konkret secara manual di dalam handler fungsi, dan dilarang mengorkestrasi provider AI/DB langsung di dalam router. Router wajib menginjeksi Domain Service via `Depends()`: ```python -# ✅ BENAR: Menggunakan Dependency Injection -@router.post("/chat/completions") +# ✅ BENAR: Menggunakan Domain Service Injection & Pydantic response_model +@router.post("/chat/completions", response_model=ChatCompletionResponse) async def chat_completion( payload: ChatRequest, - llm: BaseLLMProvider = Depends(get_llm_provider), - db: AsyncSession = Depends(get_db_session) -): - return await llm.generate_response(payload.message) + service: ConversationService = Depends(get_conversation_service), +) -> ChatCompletionResponse: + """Delegasikan prompt generation dan DB logging ke service domain.""" + return await service.generate_chat_reply(payload) ``` +> **Catatan Arsitektur:** Provider konkret (`BaseLLMProvider`) diinjeksi ke dalam `ConversationService` melalui factory di `app/api/dependencies.py`, bukan di dalam router handler. --- diff --git a/.agents/rules/agentic-workflow-constraints.md b/.agents/rules/agentic-workflow-constraints.md index 79b9078..b34a5bb 100644 --- a/.agents/rules/agentic-workflow-constraints.md +++ b/.agents/rules/agentic-workflow-constraints.md @@ -13,6 +13,7 @@ This document establishes non-negotiable architectural boundaries and safety con - **Human Accountability**: Every task has a single human developer owner. The AI agent acts as a supervised pair programmer, not an unconstrained autonomous actor. - **No Unsolicited Work**: The agent must never initiate unassigned tasks, switch tasks, or create speculative branches without explicit human developer instruction. +- **Issue Mutation Review Gate**: The agent must never autonomously create, edit, close, or delete GitHub Issues without presenting the proposal (draft/dry-run) to the developer and obtaining explicit review and approval first. - **Crash Invariant**: If the agent process terminates or crashes, **no automated state transition or recovery occurs**. The task remains in its last verified state until the developer resumes the session. - **No Autonomous Handoff (Phase 1)**: Cross-developer delegation, auto-claim bots, and autonomous cross-agent orchestrators are strictly out of scope. diff --git a/.agents/rules/cohesion-and-modularity.md b/.agents/rules/cohesion-and-modularity.md index dddb901..a0a691f 100644 --- a/.agents/rules/cohesion-and-modularity.md +++ b/.agents/rules/cohesion-and-modularity.md @@ -41,15 +41,31 @@ Panduan ini mengatur standar modularitas kode pada monorepo Eva AI menggunakan p 1. **Pemisahan I/O & Realtime dari Persistensi**: Service yang menangani koneksi real-time (WebSockets, STT/TTS streaming) tidak boleh mencampur transaksi SQL langsung di dalam perulangan event. Delegasikan operasi database ke *Persistence Collaborator*. -2. **Peran Router yang Ramping**: - Router FastAPI (`app/api/routes/`) hanya bertugas memvalidasi schema HTTP request, memanggil domain service, dan mengembalikan DTO. Dilarang menaruh query SQL mentah kompleks di dalam router handler. -3. **Cohesive Domain Services**: - Bila sebuah service mulai mengelola lebih dari 3 entitas database atau mencampur komputasi teks dengan operasi I/O, pecah menjadi service kolaborator yang terfokus. +2. **Peran Router yang Ramping (Thin Controllers)**: + Router FastAPI (`app/api/routes/`) wajib ramping (3–5 baris per handler), hanya bertugas memvalidasi schema HTTP request, memanggil domain service via `Depends()`, dan mengembalikan Pydantic `response_model`. Dilarang menaruh query SQL mentah kompleks atau kalkulasi bisnis di dalam router handler. +3. **Cohesive Domain Services & Modular Packaging**: + Bila sebuah domain memiliki beberapa sub-komponen berat (seperti panggilan suara, deteksi emosi, orkestrasi AI), kumpulkan ke dalam direktori paket `app/services/{module}/nama_service.py` dan `app/schemas/{module}/` dengan barrel export `__init__.py`, alih-alih menumpuk file flat di root folder `services/`. + Lihat panduan lengkap di [.agents/rules/backend-clean-architecture.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/backend-clean-architecture.md). --- -## 4. Checklist Saat Merancang atau Merefaktor Komponen +## 4. Prinsip Component Reuse & Single Source of Truth +Sebelum menulis elemen UI baru atau menduplikasi styling secara *inline* di dalam sebuah screen: +1. **Wajib Memeriksa Komponen Reusable yang Sudah Ada**: + - Periksa direktori komponen global `apps/eva_mobile/lib/widgets/` (misalnya `AppBackButton`, `GlassCard`, `AppSnackBar`, `CustomButton`, `CustomTextField`, `SkeletonShimmer`, `StaggeredEntrance`). + - Periksa komponen tingkat fitur di `apps/eva_mobile/lib/features//presentation/widgets/`. +2. **Dilarang Menulis Ulang Komponen Standar Secara Inline**: + - Jangan membuat tombol *back* manual (`IconButton(icon: Icon(Icons.arrow_back...))` atau `Container(...)` bulat) secara terpisah di setiap screen. Selalu gunakan `AppBackButton`. + - Jangan membuat dialog / card / snack bar secara *ad-hoc* jika sudah tersedia komponen terstandarisasi. +3. **Ekstraksi Jika Dipakai di Lebih Dari 1 Tempat**: + - Jika sebuah komponen UI memiliki pola visual atau fungsi umum yang dibutuhkan oleh $\ge 2$ layar, ekstraksi menjadi reusable widget di `lib/widgets/` alih-alih menduplikasinya. + +--- + +## 5. Checklist Saat Merancang atau Merefaktor Komponen + +- [ ] Apakah sudah memeriksa `apps/eva_mobile/lib/widgets/` untuk memastikan belum ada komponen yang melayani kebutuhan serupa (misal `AppBackButton`, `GlassCard`)? $\rightarrow$ *Wajib gunakan komponen yang sudah ada jika tersedia* - [ ] Apakah sub-widget ini memiliki `Timer` atau `AnimationController` sendiri yang bisa berjalan independen dari layarnya? $\rightarrow$ *Ekstraksi ke widgets/* - [ ] Apakah ada class `CustomPainter` yang ditulis di bawah screen? $\rightarrow$ *Ekstraksi ke widgets/* - [ ] Apakah modal bottom sheet ini cukup panjang dan memiliki alur interaksinya sendiri? $\rightarrow$ *Ekstraksi ke widgets/* diff --git a/.agents/rules/github-coordination.md b/.agents/rules/github-coordination.md index 97352ee..6ed3a14 100644 --- a/.agents/rules/github-coordination.md +++ b/.agents/rules/github-coordination.md @@ -42,6 +42,7 @@ Backlog ───> Ready ───> In Progress ───> In Review ─ ``` - **Human-Owned Tasks**: Every task has a single human developer owner. AI agents code only on the task selected by the developer and must never auto-claim or auto-assign tasks. +- **Developer Review Gate for Issue Lifecycle**: Adding, editing, closing, or deleting GitHub issues requires prior review and explicit confirmation by the human developer. The AI agent must present the draft/dry-run and await approval before modifying GitHub state. - **Evidence-Based Progress Updates**: Task transitions require concrete evidence: - `Ready ➔ In Progress`: Active task branch matching `{type}/{issue}-{slug}`. - `In Progress ➔ Blocked`: Explicit blocker reason (minimum 3 characters). diff --git a/.agents/rules/native-gh-execution.md b/.agents/rules/native-gh-execution.md index 0944173..7284724 100644 --- a/.agents/rules/native-gh-execution.md +++ b/.agents/rules/native-gh-execution.md @@ -28,26 +28,32 @@ Always use these targets unless explicitly overridden by the user: --- -## 3. Autonomous Issue & Project Management Policy +## 3. Issue & Project Management Policy (Mandatory Developer Review Gate) -When the user asks to create an issue, start a feature/bugfix, or add a task: -1. **Never ask the user to open the browser or create the issue manually.** +When the user asks to create an issue, start a feature/bugfix, or add/edit/delete a task: +1. **Never ask the user to open the browser or create/edit the issue manually.** 2. Formulate a structured GitHub Issue title and body matching repository standards: - `## Summary` - `## Scope / Out of Scope` - `## Acceptance Criteria` - `## Definition of Done` -3. Execute the automated script: - ```bash - python3 scripts/agentic/gh_project.py create-issue \ - --title "" \ - --body "<Structured Body>" \ - --type <feat|fix|refactor|docs|test|chore> \ - --status <Backlog|Ready|In Progress> \ - --priority <Urgent|High|Medium|Low> \ - --assignee ekyaaa - ``` -4. If starting work immediately on that issue: +3. **MANDATORY REVIEW GATE**: Before executing actual creation, editing, closing, or deletion on GitHub: + - Present the structured proposal / diff / impact to the developer. + - Run `--dry-run` to preview the command payload: + ```bash + python3 scripts/agentic/gh_project.py create-issue \ + --title "<Title>" \ + --body "<Structured Body>" \ + --type <feat|fix|refactor|docs|test|chore> \ + --status <Backlog|Ready|In Progress> \ + --priority <Urgent|High|Medium|Low> \ + --assignee ekyaaa \ + --dry-run + ``` + - **Ask the developer for explicit review and confirmation.** +4. **Execute Only After Explicit Developer Approval**: + - Run the script without `--dry-run` once the developer approves. +5. If starting work immediately on that approved issue: - Create the task branch using `start-task.py`: ```bash python3 scripts/agentic/start-task.py --type <type> --issue <number> --slug <slug> diff --git a/.agents/skills/flutter-workflow/SKILL.md b/.agents/skills/flutter-workflow/SKILL.md index 0abce0b..323523b 100644 --- a/.agents/skills/flutter-workflow/SKILL.md +++ b/.agents/skills/flutter-workflow/SKILL.md @@ -11,17 +11,17 @@ Runbook for developing, building, and testing `apps/eva_mobile`. From the repository root: ```bash -# Run on physical Android device connected via USB (auto adb reverse port 8888): -make mobile-usb - -# Run on Android Emulator (auto routes to 10.0.2.2:8888): -make mobile-emu - -# Run with Mock Data (no backend required): -make mobile-mock - -# Run on Chrome Browser Simulator: -make mobile-web +# DEFAULT: Local API Stack (Port 8888) +make mobile-usb # HP fisik via USB (auto adb reverse port 8888 -> 127.0.0.1:8888) +make mobile-emu # Android Emulator (auto route gateway 10.0.2.2:8888) +make mobile-web # Chrome Web Simulator (127.0.0.1:8888) +make mobile-mock # Offline Mock Data (tanpa backend) + +# CLOUD: Production Cloud Stack (eva.nexacode.dev) +make mobile-usb-cloud # HP fisik nembak production cloud +make mobile-emu-cloud # Android Emulator nembak production cloud +make mobile-web-cloud # Chrome Web nembak production cloud +make mobile-cloud # Shortcut alias ke mobile-usb-cloud ``` ## 2. Manual Profile Execution diff --git a/.agents/skills/manage-github-project/SKILL.md b/.agents/skills/manage-github-project/SKILL.md index 78ababc..7be893e 100644 --- a/.agents/skills/manage-github-project/SKILL.md +++ b/.agents/skills/manage-github-project/SKILL.md @@ -1,23 +1,69 @@ --- name: manage-github-project -description: Automate creation, management, linking, and status updates of GitHub Issues and Project v2 items natively using the host GitHub CLI. +description: Automate creation, management, linking, and status updates of GitHub Issues and Project v2 items natively using the host GitHub CLI, with mandatory developer review before any issue mutations. --- # Manage GitHub Project & Issues Runbook -This skill automates all interactions with GitHub Issues and GitHub Project #2 (`https://github.com/orgs/Nexa-Code-Studio/projects/2`) using native `gh` commands without manual web UI interaction. +This skill automates interactions with GitHub Issues and GitHub Project #2 (`https://github.com/orgs/Nexa-Code-Studio/projects/2`) using native `gh` commands without requiring manual web UI interaction. + +--- + +## ⚠️ Mandatory Rule: Developer Review Gate (Aturan Wajib Review Developer) + +> **KEBIJAKAN MUTLAK:** AI agent **DILARANG KERAS** menambah (create), mengedit (edit/update), menutup (close), atau menghapus (delete) issue maupun item Project GitHub secara sepihak/otomatis tanpa meminta developer mereview dan memberikan konfirmasi terlebih dahulu. + +### Alur Wajib (4 Langkah): + +1. **Formulasi Draf (Drafting)**: + - Siapkan rincian lengkap issue sesuai format standar repositori (`## Summary`, `## Scope / Out of Scope`, `## Acceptance Criteria`, `## Definition of Done`, label/type, assignee, initial status, priority). + - Untuk edit/hapus/tutup: siapkan nomor issue target dan parameter spesifik yang akan diubah beserta alasannya. +2. **Tampilkan Preview Draf / Dry-Run**: + - Tampilkan draf lengkap secara terstruktur kepada developer di chat, atau jalankan perintah dengan opsi `--dry-run` untuk memperlihatkan muatan persis yang akan dikirim ke GitHub. +3. **Minta Konfirmasi Developer (Explicit Prompt)**: + - Tanyakan secara eksplisit kepada developer untuk mereview dan memberikan persetujuan: + > *"Silakan review draf issue di atas sebelum saya eksekusi ke GitHub. Apakah rincian ini sudah sesuai atau ada yang perlu diperbaiki?"* +4. **Eksekusi Hanya Setelah Disetujui (Approved Execution)**: + - Jalankan perintah live (tanpa `--dry-run`) **HANYA SETELAH** developer menyatakan persetujuan secara eksplisit (misal: "Setuju", "Lanjutkan", "Oke", "Approved"). + +--- ## When to Use -- When the user asks to create an issue, task, bug, or feature without manual UI steps. -- When linking `eva-ecosystem` to Project #2. -- When querying the current items and board columns on Project #2. -- When updating status or priority on the project board. +- Ketika developer meminta untuk membuat issue, bugfix, feature, chore, atau task baru. +- Ketika developer meminta untuk mengedit title, body, label, atau assignee issue yang sudah ada. +- Ketika developer meminta untuk menutup atau menghapus issue di GitHub. +- Ketika menghubungkan repository `eva-ecosystem` ke Project #2. +- Ketika menginspeksi atau membaca board item pada Project #2. + +--- ## Execution Procedures -### 1. Create Issue and Automatically Add to Project #2 +### 1. Menambah Issue Baru (Create Issue) + +#### Langkah 1 (Review Phase - Jalankan Dry-Run): +```bash +python3 scripts/agentic/gh_project.py create-issue \ + --title "Implement Voice Activity Detection" \ + --type feat \ + --status Ready \ + --priority High \ + --assignee ekyaaa \ + --body "## Summary +Voice activity detection to reduce latency. + +## Acceptance Criteria +- [ ] VAD threshold configurable +- [ ] Passing unit tests + +## Definition of Done +- Code reviewed and tests passing" \ + --dry-run +``` +*Tampilkan output draf kepada developer dan tunggu konfirmasi.* +#### Langkah 2 (Execution Phase - Setelah Developer Setuju): ```bash python3 scripts/agentic/gh_project.py create-issue \ --title "Implement Voice Activity Detection" \ @@ -36,7 +82,74 @@ Voice activity detection to reduce latency. - Code reviewed and tests passing" ``` -### 2. Link Repository `eva-ecosystem` to Project #2 +--- + +### 2. Mengedit Issue yang Ada (Edit Issue) + +#### Langkah 1 (Review Phase - Jalankan Dry-Run): +```bash +python3 scripts/agentic/gh_project.py edit-issue \ + --issue 128 \ + --title "Updated Title" \ + --add-label "backend" \ + --dry-run +``` +*Tampilkan perubahan kepada developer dan minta review.* + +#### Langkah 2 (Execution Phase - Setelah Developer Setuju): +```bash +python3 scripts/agentic/gh_project.py edit-issue \ + --issue 128 \ + --title "Updated Title" \ + --add-label "backend" +``` + +--- + +### 3. Menutup Issue (Close Issue) + +#### Langkah 1 (Review Phase - Jalankan Dry-Run): +```bash +python3 scripts/agentic/gh_project.py close-issue \ + --issue 128 \ + --comment "Resolved via PR #15" \ + --reason completed \ + --dry-run +``` +*Minta konfirmasi developer sebelum menutup issue.* + +#### Langkah 2 (Execution Phase - Setelah Developer Setuju): +```bash +python3 scripts/agentic/gh_project.py close-issue \ + --issue 128 \ + --comment "Resolved via PR #15" \ + --reason completed +``` + +--- + +### 4. Menghapus Issue (Delete Issue) + +> [!CAUTION] +> Menghapus issue bersifat destruktif dan permanen. Pastikan developer secara sadar dan eksplisit menginstruksikan penghapusan. + +#### Langkah 1 (Review Phase - Jalankan Dry-Run): +```bash +python3 scripts/agentic/gh_project.py delete-issue \ + --issue 128 \ + --dry-run +``` +*Tanyakan developer: "Apakah Anda yakin ingin menghapus issue #128 secara permanen?"* + +#### Langkah 2 (Execution Phase - Setelah Developer Setuju): +```bash +python3 scripts/agentic/gh_project.py delete-issue \ + --issue 128 +``` + +--- + +### 5. Link Repository `eva-ecosystem` ke Project #2 (Read/Link Only) ```bash python3 scripts/agentic/gh_project.py link-repo \ @@ -45,7 +158,9 @@ python3 scripts/agentic/gh_project.py link-repo \ --project 2 ``` -### 3. View Project #2 Items and Statuses +--- + +### 6. Melihat Item & Status Project #2 (Read-Only) ```bash python3 scripts/agentic/gh_project.py view-project \ diff --git a/.agents/skills/monorepo-ops/SKILL.md b/.agents/skills/monorepo-ops/SKILL.md index 9e4fd47..1b397ab 100644 --- a/.agents/skills/monorepo-ops/SKILL.md +++ b/.agents/skills/monorepo-ops/SKILL.md @@ -64,7 +64,7 @@ make db-shell ## 4. Tests & Quality Checks ```bash -# Run backend & AI package tests via Docker +# Run backend & AI package tests via Docker (includes Clean Architecture guardrail test) make test-backend # Run mobile tests @@ -74,3 +74,10 @@ make test-mobile make lint make format ``` + +> **Clean Architecture Guardrail:** +> Running `make test-backend` automatically validates `test_clean_architecture.py`, ensuring: +> 1. Zero loose `.py` files in `app/services/` or `app/schemas/` root. +> 2. Zero inline SQL queries (`select()`, `db.execute()`, `db.commit()`) in router files. +> 3. Zero duplicated `_get_current_user` private functions. +> 4. All router endpoints are thin controllers ($\le 15$ lines) returning Pydantic `response_model`. diff --git a/.agents/skills/project-state-sync/SKILL.md b/.agents/skills/project-state-sync/SKILL.md index faf9d28..aead8b6 100644 --- a/.agents/skills/project-state-sync/SKILL.md +++ b/.agents/skills/project-state-sync/SKILL.md @@ -10,6 +10,7 @@ This skill defines the evidence requirements and operational procedures for tran ## Overview State transitions are **evidence-based and deterministic**: +- Mutasi lifecycle issue (tambah, edit, tutup, atau hapus) wajib melalui review dan persetujuan eksplisit developer. - The agent must **never** infer task completion merely because code was edited. - A passing unit test alone does **not** mark a task `Done`. - An agent crash or process termination never triggers automated state transitions. diff --git a/.agents/skills/sync-project-state/SKILL.md b/.agents/skills/sync-project-state/SKILL.md index 3523bb4..a833e47 100644 --- a/.agents/skills/sync-project-state/SKILL.md +++ b/.agents/skills/sync-project-state/SKILL.md @@ -7,6 +7,9 @@ description: Synchronize task status and GitHub Project v2 based on verifiable e This skill validates concrete evidence before updating a task's state in `.agent/task-state.json` and the bound GitHub Project v2. +> [!IMPORTANT] +> **Developer Review Gate**: Mutasi status terminal (seperti menutup issue ke `Done`) atau modifikasi issue GitHub harus didahului konfirmasi dan review dari developer. + ## Status Machine ```text diff --git a/AGENTS.md b/AGENTS.md index a0a5e0e..0f2cfc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,9 +32,11 @@ - **Sweet-Spot Cohesion & Modularity**: - Keep files cohesive; extract independent timers, custom painters, and multi-step modals into dedicated widget files while keeping local layout together. - See [.agents/rules/cohesion-and-modularity.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/cohesion-and-modularity.md) for natural separation criteria. +- **Component Reuse & Single Source of Truth**: + - Before creating new UI elements or duplicate styling, always verify and reuse existing widgets in `apps/eva_mobile/lib/widgets/` (e.g. `AppBackButton`, `GlassCard`, `AppSnackBar`) or feature widgets. Never declare manual inline back buttons across screens. - **Hierarchical Workspace Scopes**: - Flutter Mobile: Follow [apps/eva_mobile/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/eva_mobile/AGENTS.md) (Clean Architecture, Riverpod DI, Pure Dart models). - - Backend API: Follow [apps/backend/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/backend/AGENTS.md) (FastAPI routes, Pydantic schemas, Alembic migrations, FastMCP). + - Backend API: Follow [apps/backend/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/apps/backend/AGENTS.md) and [.agents/rules/backend-clean-architecture.md](file:///home/mashupsoat/Project/eva-ecosystem/.agents/rules/backend-clean-architecture.md) (Thin routes 3-5 lines, modular services/schemas, centralized DI, Alembic migrations). - AI & Orchestration: Follow [packages/ai/AGENTS.md](file:///home/mashupsoat/Project/eva-ecosystem/packages/ai/AGENTS.md) (Abstract Base Classes, LLM/TTS factories, graceful fallbacks). - **Available Skills**: - `monorepo-ops`: Docker dev/prod, Alembic migrations, database resets, and backend service runner. @@ -61,6 +63,7 @@ ## 5. Supervised Autonomous Development Coordination Policies - **Human-Owned Tasks**: Every task has a single human developer owner. AI agents code only on the task selected by the developer and must never auto-claim or auto-assign tasks. +- **Issue Mutation Review Gate**: The AI agent must never create, edit, close, or delete GitHub Issues autonomously. All additions, updates, or deletions must be presented to the developer (via draft/dry-run) and receive explicit review and approval before execution. - **Identity Preflight Mandatory**: Before modifying code or updating task status, verify local identity via `python3 scripts/agentic/doctor.py`. If identity is missing, prompt developer to run `python3 scripts/agentic/bootstrap.py`. - **Session Context Reconciliation**: At the beginning of a session, inspect active context via `python3 scripts/agentic/tasks.py` to reconcile local and remote state. - **Strict Branch & Worktree Naming**: All branches must follow `{type}/{issue-number}-{short-kebab-slug}` where type is one of `feat`, `fix`, `refactor`, `docs`, `test`, `chore`. Personal branches (`dev/{name}`) are prohibited. diff --git a/Makefile b/Makefile index 898c06c..955370e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev dev-d dev-build dev-api dev-mcp dev-worker prod prod-down logs logs-api logs-worker logs-prod ps down db-shell migrate migration-gen db-reset seed mobile-usb mobile-emu mobile-mock mobile-web test test-backend test-mobile lint format doctor tasks bootstrap test-agentic link-project view-project +.PHONY: help dev dev-d dev-build dev-api dev-mcp dev-worker prod prod-down logs logs-api logs-worker logs-prod ps down db-shell migrate migration-gen db-reset seed mobile-usb mobile-emu mobile-mock mobile-hybrid mobile-emu-hybrid mobile-web-hybrid mobile-web mobile-usb-cloud mobile-emu-cloud mobile-web-cloud mobile-cloud test test-backend test-mobile lint format doctor tasks bootstrap test-agentic link-project view-project help: @echo "EVA Ecosystem Management Commands (Full Docker Environment):" @@ -29,10 +29,17 @@ help: @echo " make seed - Jalankan seeder akun & mental health knowledge via Docker" @echo "" @echo " Mobile Applications (Eva Mobile):" - @echo " make mobile-usb - Jalankan Eva Mobile di HP Fisik via USB (auto adb reverse 8888)" - @echo " make mobile-emu - Jalankan Eva Mobile di Android Emulator (route 10.0.2.2:8888)" - @echo " make mobile-mock - Jalankan Eva Mobile dengan Mock Data lokal" - @echo " make mobile-web - Jalankan Eva Mobile di Chrome Web Simulator" + @echo " make mobile-usb - Jalankan di HP Fisik (DEFAULT: API Lokal via reverse 8888)" + @echo " make mobile-emu - Jalankan di Android Emulator (DEFAULT: API Lokal 10.0.2.2:8888)" + @echo " make mobile-web - Jalankan di Chrome Web (DEFAULT: API Lokal 127.0.0.1:8888)" + @echo " make mobile-mock - Jalankan dengan Mock Data lokal (offline)" + @echo " make mobile-hybrid - Jalankan mode Hybrid di HP Fisik (Remote API + Auto Fallback Mock)" + @echo " make mobile-emu-hybrid - Jalankan mode Hybrid di Android Emulator" + @echo " make mobile-web-hybrid - Jalankan mode Hybrid di Chrome Web" + @echo " make mobile-usb-cloud - Jalankan di HP Fisik nembak ke Cloud (eva.nexacode.dev)" + @echo " make mobile-emu-cloud - Jalankan di Android Emulator nembak ke Cloud (eva.nexacode.dev)" + @echo " make mobile-web-cloud - Jalankan di Chrome Web nembak ke Cloud (eva.nexacode.dev)" + @echo " make mobile-cloud - Shortcut jalankan mobile nembak ke Cloud" @echo "" @echo " Testing & Code Quality:" @echo " make test - Jalankan seluruh test suite (Backend via Docker & Mobile)" @@ -120,7 +127,8 @@ seed: docker compose exec -T api python -m app.db.seed # ------------------------------------------------------------------------------ -# Mobile Applications Runner (Physical USB / Emulator / Mock / Web) +# Mobile Applications Runner (Physical USB / Emulator / Web / Mock) +# DEFAULT: Local API (8888). Append '-cloud' to connect to production cloud. # ------------------------------------------------------------------------------ mobile-usb: @./scripts/run_mobile.sh usb $(ARGS) @@ -128,11 +136,31 @@ mobile-usb: mobile-emu: @./scripts/run_mobile.sh emu $(ARGS) +mobile-web: + @./scripts/run_mobile.sh web $(ARGS) + mobile-mock: @./scripts/run_mobile.sh mock $(ARGS) -mobile-web: - @./scripts/run_mobile.sh web $(ARGS) +mobile-hybrid: + @./scripts/run_mobile.sh hybrid $(ARGS) + +mobile-emu-hybrid: + @./scripts/run_mobile.sh emu-hybrid $(ARGS) + +mobile-web-hybrid: + @./scripts/run_mobile.sh web-hybrid $(ARGS) + +mobile-usb-cloud: + @./scripts/run_mobile.sh usb-cloud $(ARGS) + +mobile-emu-cloud: + @./scripts/run_mobile.sh emu-cloud $(ARGS) + +mobile-web-cloud: + @./scripts/run_mobile.sh web-cloud $(ARGS) + +mobile-cloud: mobile-usb-cloud # ------------------------------------------------------------------------------ # Testing & Code Quality diff --git a/scripts/agentic/gh_project.py b/scripts/agentic/gh_project.py index 8795772..2daabdd 100755 --- a/scripts/agentic/gh_project.py +++ b/scripts/agentic/gh_project.py @@ -220,6 +220,115 @@ def view_project(project_number: int = DEFAULT_PROJECT_NUMBER, owner: str = DEFA else: print(f"Error viewing project #{project_number}: {res.stderr.strip()}", file=sys.stderr) +def edit_issue( + issue_number: int, + title: Optional[str] = None, + body: Optional[str] = None, + add_labels: Optional[List[str]] = None, + remove_labels: Optional[List[str]] = None, + add_assignees: Optional[List[str]] = None, + remove_assignees: Optional[List[str]] = None, + repo: str = f"{DEFAULT_ORG}/{DEFAULT_REPO}", + dry_run: bool = False, +) -> Dict[str, Any]: + """Edits an existing GitHub issue.""" + if dry_run: + print(f"\n--- DRY-RUN: GitHub Issue #{issue_number} Edit ---") + print(f"Repository: {repo}") + print(f"Issue Number: #{issue_number}") + if title: + print(f"New Title: {title}") + if body: + print(f"New Body:\n{body}\n") + if add_labels: + print(f"Add Labels: {', '.join(add_labels)}") + if remove_labels: + print(f"Remove Labels: {', '.join(remove_labels)}") + if add_assignees: + print(f"Add Assignees: {', '.join(add_assignees)}") + if remove_assignees: + print(f"Remove Assignees: {', '.join(remove_assignees)}") + print("--------------------------------------------------") + return {"status": "dry_run", "issue_number": issue_number} + + cmd = ["issue", "edit", str(issue_number), "--repo", repo] + if title: + cmd.extend(["--title", sanitize_text(title, max_len=256)]) + if body: + cmd.extend(["--body", body.strip()]) + if add_labels: + for lbl in add_labels: + cmd.extend(["--add-label", lbl]) + if remove_labels: + for lbl in remove_labels: + cmd.extend(["--remove-label", lbl]) + if add_assignees: + for a in add_assignees: + cmd.extend(["--add-assignee", a]) + if remove_assignees: + for a in remove_assignees: + cmd.extend(["--remove-assignee", a]) + + print(f"✏️ Editing issue #{issue_number} in {repo}...") + res = exec_gh(cmd) + if res.returncode != 0: + raise RuntimeError(f"Failed to edit issue #{issue_number}: {res.stderr.strip() or res.stdout.strip()}") + + print(f"✅ Issue #{issue_number} edited successfully.") + return {"status": "success", "issue_number": issue_number} + +def close_issue( + issue_number: int, + comment: Optional[str] = None, + reason: str = "completed", + repo: str = f"{DEFAULT_ORG}/{DEFAULT_REPO}", + dry_run: bool = False, +) -> Dict[str, Any]: + """Closes an existing GitHub issue.""" + if dry_run: + print(f"\n--- DRY-RUN: GitHub Issue #{issue_number} Close ---") + print(f"Repository: {repo}") + print(f"Issue Number: #{issue_number}") + print(f"Reason: {reason}") + if comment: + print(f"Comment: {comment}") + print("---------------------------------------------------") + return {"status": "dry_run", "issue_number": issue_number} + + cmd = ["issue", "close", str(issue_number), "--repo", repo, "--reason", reason] + if comment: + cmd.extend(["--comment", comment.strip()]) + + print(f"🔒 Closing issue #{issue_number} in {repo}...") + res = exec_gh(cmd) + if res.returncode != 0: + raise RuntimeError(f"Failed to close issue #{issue_number}: {res.stderr.strip() or res.stdout.strip()}") + + print(f"✅ Issue #{issue_number} closed successfully.") + return {"status": "success", "issue_number": issue_number} + +def delete_issue( + issue_number: int, + repo: str = f"{DEFAULT_ORG}/{DEFAULT_REPO}", + dry_run: bool = False, +) -> Dict[str, Any]: + """Deletes an existing GitHub issue.""" + if dry_run: + print(f"\n--- DRY-RUN: GitHub Issue #{issue_number} Delete ---") + print(f"Repository: {repo}") + print(f"Issue Number: #{issue_number}") + print("---------------------------------------------------") + return {"status": "dry_run", "issue_number": issue_number} + + cmd = ["issue", "delete", str(issue_number), "--repo", repo, "--yes"] + print(f"🗑️ Deleting issue #{issue_number} in {repo}...") + res = exec_gh(cmd) + if res.returncode != 0: + raise RuntimeError(f"Failed to delete issue #{issue_number}: {res.stderr.strip() or res.stdout.strip()}") + + print(f"✅ Issue #{issue_number} deleted successfully.") + return {"status": "success", "issue_number": issue_number} + def main(): parser = argparse.ArgumentParser(description="Native GitHub CLI Project & Issue Management Automation") subparsers = parser.add_subparsers(dest="command", help="Command to execute") @@ -248,6 +357,32 @@ def main(): p_view.add_argument("--project", type=int, default=DEFAULT_PROJECT_NUMBER, help="Project number") p_view.add_argument("--owner", type=str, default=DEFAULT_ORG, help="Org owner") + # Command: edit-issue + p_edit = subparsers.add_parser("edit-issue", help="Edit existing issue") + p_edit.add_argument("--issue", type=int, required=True, help="Issue number to edit") + p_edit.add_argument("--title", type=str, help="New issue title") + p_edit.add_argument("--body", type=str, help="New issue body") + p_edit.add_argument("--add-label", action="append", help="Label to add (repeatable)") + p_edit.add_argument("--remove-label", action="append", help="Label to remove (repeatable)") + p_edit.add_argument("--add-assignee", action="append", help="Assignee to add (repeatable)") + p_edit.add_argument("--remove-assignee", action="append", help="Assignee to remove (repeatable)") + p_edit.add_argument("--repo", type=str, default=f"{DEFAULT_ORG}/{DEFAULT_REPO}", help="Target repo") + p_edit.add_argument("--dry-run", action="store_true", help="Preview without editing") + + # Command: close-issue + p_close = subparsers.add_parser("close-issue", help="Close existing issue") + p_close.add_argument("--issue", type=int, required=True, help="Issue number to close") + p_close.add_argument("--comment", type=str, help="Closing comment") + p_close.add_argument("--reason", choices=["completed", "not planned"], default="completed", help="Close reason") + p_close.add_argument("--repo", type=str, default=f"{DEFAULT_ORG}/{DEFAULT_REPO}", help="Target repo") + p_close.add_argument("--dry-run", action="store_true", help="Preview without closing") + + # Command: delete-issue + p_del = subparsers.add_parser("delete-issue", help="Delete existing issue") + p_del.add_argument("--issue", type=int, required=True, help="Issue number to delete") + p_del.add_argument("--repo", type=str, default=f"{DEFAULT_ORG}/{DEFAULT_REPO}", help="Target repo") + p_del.add_argument("--dry-run", action="store_true", help="Preview without deleting") + args = parser.parse_args() if not args.command: @@ -268,6 +403,32 @@ def main(): project_owner=args.owner, dry_run=args.dry_run, ) + elif args.command == "edit-issue": + edit_issue( + issue_number=args.issue, + title=args.title, + body=args.body, + add_labels=args.add_label, + remove_labels=args.remove_label, + add_assignees=args.add_assignee, + remove_assignees=args.remove_assignee, + repo=args.repo, + dry_run=args.dry_run, + ) + elif args.command == "close-issue": + close_issue( + issue_number=args.issue, + comment=args.comment, + reason=args.reason, + repo=args.repo, + dry_run=args.dry_run, + ) + elif args.command == "delete-issue": + delete_issue( + issue_number=args.issue, + repo=args.repo, + dry_run=args.dry_run, + ) elif args.command == "link-repo": success = link_repository_to_project( repo_name=args.repo, diff --git a/tests/agentic/test_gh_project.py b/tests/agentic/test_gh_project.py index f4c3c40..06189cb 100644 --- a/tests/agentic/test_gh_project.py +++ b/tests/agentic/test_gh_project.py @@ -8,6 +8,9 @@ from unittest.mock import patch, MagicMock from scripts.agentic.gh_project import ( create_issue, + edit_issue, + close_issue, + delete_issue, link_repository_to_project, get_gh_env, DEFAULT_ORG, @@ -80,5 +83,92 @@ def test_create_issue_success(self, mock_exec): self.assertEqual(result.get("issue_number"), 100) self.assertEqual(result.get("item_id"), "PVTI_lADOAA") + def test_edit_issue_dry_run(self): + result = edit_issue( + issue_number=100, + title="Updated Title", + body="Updated Body", + add_labels=["bug"], + dry_run=True, + ) + self.assertEqual(result.get("status"), "dry_run") + self.assertEqual(result.get("issue_number"), 100) + + @patch("scripts.agentic.gh_project.exec_gh") + def test_edit_issue_success(self, mock_exec): + mock_res = MagicMock() + mock_res.returncode = 0 + mock_res.stdout = "" + mock_exec.return_value = mock_res + + result = edit_issue( + issue_number=100, + title="Updated Title", + add_labels=["bug"], + dry_run=False, + ) + self.assertEqual(result.get("status"), "success") + self.assertEqual(result.get("issue_number"), 100) + mock_exec.assert_called_once() + args = mock_exec.call_args[0][0] + self.assertIn("edit", args) + self.assertIn("100", args) + self.assertIn("--add-label", args) + self.assertIn("bug", args) + + def test_close_issue_dry_run(self): + result = close_issue( + issue_number=100, + comment="Completed in PR #12", + dry_run=True, + ) + self.assertEqual(result.get("status"), "dry_run") + self.assertEqual(result.get("issue_number"), 100) + + @patch("scripts.agentic.gh_project.exec_gh") + def test_close_issue_success(self, mock_exec): + mock_res = MagicMock() + mock_res.returncode = 0 + mock_res.stdout = "" + mock_exec.return_value = mock_res + + result = close_issue( + issue_number=100, + comment="Done!", + dry_run=False, + ) + self.assertEqual(result.get("status"), "success") + mock_exec.assert_called_once() + args = mock_exec.call_args[0][0] + self.assertIn("close", args) + self.assertIn("100", args) + self.assertIn("--comment", args) + + def test_delete_issue_dry_run(self): + result = delete_issue( + issue_number=100, + dry_run=True, + ) + self.assertEqual(result.get("status"), "dry_run") + self.assertEqual(result.get("issue_number"), 100) + + @patch("scripts.agentic.gh_project.exec_gh") + def test_delete_issue_success(self, mock_exec): + mock_res = MagicMock() + mock_res.returncode = 0 + mock_res.stdout = "" + mock_exec.return_value = mock_res + + result = delete_issue( + issue_number=100, + dry_run=False, + ) + self.assertEqual(result.get("status"), "success") + mock_exec.assert_called_once() + args = mock_exec.call_args[0][0] + self.assertIn("delete", args) + self.assertIn("100", args) + self.assertIn("--yes", args) + if __name__ == "__main__": unittest.main()